From e2a3bb555d96722adcd74e882d4f3e736e495e7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 13:22:04 +0000 Subject: [PATCH 1/3] feat(ev): add Tesla Wall Connector Gen 3 local HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register tesla-wc as a LAN HTTP EV provider next to Easee and Zaptec. The wizard and Settings take a host; the picker reads /api/1/version with a vitals fallback. The Lua driver emits plug state, power and session energy. The box has no local current setpoint — commands ack and tesla_vehicle steers a Tesla. Testdata until the device-drivers pin includes it. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- .changeset/tesla-wall-connector.md | 5 + go/internal/config/config.go | 16 +- go/internal/config/evcharger_test.go | 30 +++ .../drivers/tesla_wall_connector_test.go | 168 ++++++++++++ .../drivers/testdata/tesla_wall_connector.lua | 248 ++++++++++++++++++ go/internal/evcloud/tesla_wc.go | 156 +++++++++++ go/internal/evcloud/tesla_wc_test.go | 141 ++++++++++ web/settings/tabs/devices.js | 3 +- web/settings/tabs/ev.js | 47 +++- web/setup.html | 7 + web/setup.js | 52 +++- web/setup.test.mjs | 18 ++ 12 files changed, 863 insertions(+), 28 deletions(-) create mode 100644 .changeset/tesla-wall-connector.md create mode 100644 go/internal/drivers/tesla_wall_connector_test.go create mode 100644 go/internal/drivers/testdata/tesla_wall_connector.lua create mode 100644 go/internal/evcloud/tesla_wc.go create mode 100644 go/internal/evcloud/tesla_wc_test.go diff --git a/.changeset/tesla-wall-connector.md b/.changeset/tesla-wall-connector.md new file mode 100644 index 000000000..b0779fc80 --- /dev/null +++ b/.changeset/tesla-wall-connector.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Tesla Wall Connector Gen 3 can be added as a local EV charger. The setup wizard and Settings → EV take the box's LAN address; FTW reads plug state, power and session energy over HTTP with no Tesla account. The wall connector cannot take a current setpoint — steer a Tesla through tesla_vehicle. Experimental until a live Gen 3 has been exercised. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 3aae51e36..89be9ff0e 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -323,14 +323,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" | "zaptec" | "ctek" + Provider string `yaml:"provider" json:"provider"` // "easee" | "zaptec" | "tesla-wc" | "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 and - // Zaptec, unused by local Modbus providers like CTEK. + // Zaptec, unused by local providers (CTEK Modbus, Tesla Wall Connector). Username string `yaml:"username,omitempty" json:"username,omitempty"` Password string `yaml:"-" json:"password,omitempty"` // persisted in state.db, not YAML @@ -532,6 +532,16 @@ func (e *EVCharger) Validate() error { if e.Modbus != nil { return fmt.Errorf("ev_charger.modbus: not valid for provider %s (HTTP transport)", e.Provider) } + case "tesla-wc": + // Local HTTP: LAN origin in http.base_url, no cloud account. + // Empty base_url is allowed so the wizard can write provider + // intent before the host is filled in. + if e.Modbus != nil { + return errors.New("ev_charger.modbus: not valid for provider tesla-wc (HTTP transport)") + } + if e.Username != "" || e.Password != "" { + return errors.New("ev_charger: username/password not valid for provider tesla-wc") + } case "ctek": if e.Modbus == nil || e.Modbus.Host == "" { return errors.New("ev_charger.modbus.host: required for provider ctek") @@ -549,7 +559,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, zaptec, ctek)", e.Provider) + return fmt.Errorf("ev_charger.provider %q: not supported (valid: easee, zaptec, tesla-wc, ctek)", e.Provider) } return nil } diff --git a/go/internal/config/evcharger_test.go b/go/internal/config/evcharger_test.go index 167fa2c0f..42817bc4d 100644 --- a/go/internal/config/evcharger_test.go +++ b/go/internal/config/evcharger_test.go @@ -114,6 +114,36 @@ func TestEVChargerValidateZaptec(t *testing.T) { } } +func TestEVChargerValidateTeslaWC(t *testing.T) { + cases := []struct { + name string + e EVCharger + wantErr string + }{ + {"happy", EVCharger{Provider: "tesla-wc", HTTP: &EVChargerHTTP{BaseURL: "http://192.168.1.50"}}, ""}, + {"empty host allowed (wizard placeholder)", EVCharger{Provider: "tesla-wc"}, ""}, + {"modbus block rejected", EVCharger{Provider: "tesla-wc", Modbus: &EVChargerModbus{Host: "h"}}, "modbus"}, + {"auth rejected", EVCharger{Provider: "tesla-wc", HTTP: &EVChargerHTTP{BaseURL: "http://10.0.0.8"}, Username: "u"}, "username/password"}, + } + 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/tesla_wall_connector_test.go b/go/internal/drivers/tesla_wall_connector_test.go new file mode 100644 index 000000000..3d9ce5eef --- /dev/null +++ b/go/internal/drivers/tesla_wall_connector_test.go @@ -0,0 +1,168 @@ +package drivers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func teslaWCDriverPath(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", "tesla_wall_connector.lua") +} + +type teslaWCFake struct { + versionHits int + vitalsHits int + vitalsBody []byte +} + +func (f *teslaWCFake) handler(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/1/version": + f.versionHits++ + _ = json.NewEncoder(w).Encode(map[string]string{ + "serial_number": "TWC-SN-1", + "part_number": "1529455-02-J", + "firmware_version": "22.36.1", + }) + case "/api/1/vitals": + f.vitalsHits++ + if len(f.vitalsBody) > 0 { + _, _ = w.Write(f.vitalsBody) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "contactor_closed": true, + "vehicle_connected": true, + "grid_v": 230.0, + "vehicle_current_a": 16.0, + "currentA_a": 16.1, + "currentB_a": 16.0, + "currentC_a": 16.2, + "voltageA_v": 230.0, + "voltageB_v": 229.5, + "voltageC_v": 230.4, + "session_energy_wh": 4200.0, + "evse_state": 11, + }) + default: + http.Error(w, "unknown "+r.URL.Path, http.StatusNotFound) + } +} + +func TestTeslaWallConnectorInitPollAndReadonlyCommands(t *testing.T) { + fake := &teslaWCFake{} + srv := httptest.NewServer(http.HandlerFunc(fake.handler)) + defer srv.Close() + + tel := telemetry.NewStore() + env := NewHostEnv("tesla-wc", tel).WithHTTP() + d, err := NewLuaDriver(teslaWCDriverPath(t), env) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + + if err := d.Init(context.Background(), map[string]any{ + "base_url": srv.URL, + }); err != nil { + t.Fatalf("init: %v", err) + } + if fake.versionHits != 1 { + t.Fatalf("version hits=%d, want 1", fake.versionHits) + } + + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + reading := tel.Get("tesla-wc", telemetry.DerEV) + if reading == nil { + t.Fatal("no EV reading after poll") + } + // 230*16.1 + 229.5*16.0 + 230.4*16.2 = 3703 + 3672 + 3732.48 = 11107.48 + if reading.RawW < 11100 || reading.RawW > 11120 { + t.Errorf("EV W=%v, want ~11107 (sum of V×A)", 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", extra["session_wh"]) + } + + if err := d.Command(context.Background(), []byte(`{"action":"ev_set_current","power_w":11040}`)); err != nil { + t.Fatalf("ev_set_current should ack: %v", err) + } + if err := d.Command(context.Background(), []byte(`{"action":"ev_pause"}`)); err != nil { + t.Fatalf("ev_pause should ack: %v", err) + } + if err := d.DefaultMode(); err != nil { + t.Fatalf("default mode: %v", err) + } +} + +func TestTeslaWallConnectorRepairsBareNaN(t *testing.T) { + fake := &teslaWCFake{vitalsBody: []byte(`{"vehicle_connected":false,"evse_state":1,"grid_v":nan,"session_energy_wh":0}`)} + srv := httptest.NewServer(http.HandlerFunc(fake.handler)) + defer srv.Close() + + tel := telemetry.NewStore() + env := NewHostEnv("tesla-wc", tel).WithHTTP() + d, err := NewLuaDriver(teslaWCDriverPath(t), env) + if err != nil { + t.Fatalf("load: %v", err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), map[string]any{"base_url": srv.URL}); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + if tel.Get("tesla-wc", telemetry.DerEV) == nil { + t.Fatal("expected EV reading after repaired nan JSON") + } +} + +func TestTeslaWallConnectorHostConfig(t *testing.T) { + fake := &teslaWCFake{} + srv := httptest.NewServer(http.HandlerFunc(fake.handler)) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "http://") + tel := telemetry.NewStore() + env := NewHostEnv("tesla-wc", tel).WithHTTP() + d, err := NewLuaDriver(teslaWCDriverPath(t), env) + if err != nil { + t.Fatalf("load: %v", err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), map[string]any{"host": host}); err != nil { + t.Fatalf("init with host: %v", err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + if tel.Get("tesla-wc", telemetry.DerEV) == nil { + t.Fatal("expected EV reading when configured with bare host") + } +} diff --git a/go/internal/drivers/testdata/tesla_wall_connector.lua b/go/internal/drivers/testdata/tesla_wall_connector.lua new file mode 100644 index 000000000..d7633fd1b --- /dev/null +++ b/go/internal/drivers/testdata/tesla_wall_connector.lua @@ -0,0 +1,248 @@ +-- Tesla Wall Connector Gen 3 (local HTTP). +-- +-- Protocol: unauthenticated GET on the LAN. +-- /api/1/vitals live state, currents, voltages, session energy +-- /api/1/version serial / part / firmware (once) +-- Canonical copy belongs in srcfl/device-drivers as tesla_wall_connector.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. +-- +-- The wall connector has no local current setpoint. ev_set_current, +-- ev_pause and ev_resume are accepted so the planner does not mark the +-- driver failed; they do not write hardware. Steer a Tesla through +-- tesla_vehicle. Sign convention: positive W is charging. +-- +-- Config: +-- host LAN hostname or IP (no scheme) +-- base_url optional origin override (tests) +-- split_phase true for North-American split-phase power (grid_v × vehicle_a) +-- +-- verification_status is experimental until a live Gen 3 has been +-- exercised end-to-end. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "tesla-wall-connector", + name = "Tesla Wall Connector", + manufacturer = "Tesla", + version = "0.1.0", + protocols = { "http" }, + capabilities = { "ev" }, + description = "Tesla Wall Connector Gen 3 on the LAN. Observation only; no cloud account.", + homepage = "https://www.tesla.com/support/charging/wall-connector", + authors = { "FTW contributors" }, + tested_models = { "Wall Connector Gen 3" }, + verification_status = "experimental", +} + +PROTOCOL = "http" + +-- EVSE state codes documented by the ioBroker tesla-wallconnector3 adapter +-- (public field notes on /api/1/vitals). 3, 5, 7, 12 are unused. +local EVSE_IDLE = 1 +local EVSE_CONNECTED = 2 +local EVSE_READY = 4 +local EVSE_HANDSHAKE = 6 +local EVSE_FINISHED = 8 +local EVSE_WAIT_CAR = 9 +local EVSE_CHARGE_LOW = 10 +local EVSE_CHARGE_FULL = 11 + +local BASE_URL = nil +local split_phase = false +local warned_readonly = false + +local function as_number(v) + if type(v) == "number" then return v end + if v == nil then return nil end + return tonumber(v) +end + +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 + +-- Some Tesla firmware emits bare `nan` or drops the closing brace. +-- Repair before json_decode so a single bad sample does not stall poll. +local function repair_json(raw) + if raw == nil then return "" end + raw = tostring(raw) + raw = raw:gsub("[Nn][Aa][Nn]", "null") + local opens = 0 + local closes = 0 + for i = 1, #raw do + local c = raw:sub(i, i) + if c == "{" then opens = opens + 1 + elseif c == "}" then closes = closes + 1 + end + end + while closes < opens do + raw = raw .. "}" + closes = closes + 1 + end + return raw +end + +local function decode_json(raw) + if raw == nil or raw == "" then return nil, "empty body" end + local data, err = host.json_decode(repair_json(raw)) + if data == nil then return nil, tostring(err or "decode failed") end + return data, nil +end + +local function origin_from_config(config) + if config.base_url and tostring(config.base_url) ~= "" then + return tostring(config.base_url):gsub("/$", "") + end + local host = config.host or config.ip + if not host or host == "" then return nil end + host = tostring(host) + if host:find("://", 1, true) then + return host:gsub("/$", "") + end + return "http://" .. host +end + +local function phase_power(v, a) + v = as_number(v) or 0 + a = as_number(a) or 0 + -- Ghost voltages (~1–3 V) appear on unused legs; ignore them. + if v < 50 then return 0 end + if a < 0 then a = 0 end + return v * a +end + +local function charge_power(vitals) + if split_phase then + local w = (as_number(vitals.grid_v) or 0) * (as_number(vitals.vehicle_current_a) or 0) + if w < 0 then w = 0 end + return w + end + local w = phase_power(vitals.voltageA_v, vitals.currentA_a) + + phase_power(vitals.voltageB_v, vitals.currentB_a) + + phase_power(vitals.voltageC_v, vitals.currentC_a) + if w <= 0 then + local fallback = (as_number(vitals.grid_v) or 0) * (as_number(vitals.vehicle_current_a) or 0) + if fallback > 0 then w = fallback end + end + return w +end + +local function active_phases(vitals) + local n = 0 + if phase_power(vitals.voltageA_v, 1) > 0 then n = n + 1 end + if phase_power(vitals.voltageB_v, 1) > 0 then n = n + 1 end + if phase_power(vitals.voltageC_v, 1) > 0 then n = n + 1 end + if n < 1 then n = 1 end + return n +end + +local function is_charging(state, vitals) + if state == EVSE_CHARGE_LOW or state == EVSE_CHARGE_FULL then return true end + return vitals.contactor_closed == true +end + +local function is_connected(state, vitals) + if vitals.vehicle_connected == true then return true end + return state == EVSE_CONNECTED or state == EVSE_READY + or state == EVSE_HANDSHAKE or state == EVSE_FINISHED + or state == EVSE_WAIT_CAR or is_charging(state, vitals) +end + +function driver_init(config) + host.set_make("Tesla") + host.set_model("Wall Connector Gen 3") + config = config or {} + BASE_URL = origin_from_config(config) + if not BASE_URL then + error("Tesla Wall Connector: host or base_url required") + end + if config.split_phase == true or config.split_phase == "true" then + split_phase = true + end + + local raw, err = host.http_get(BASE_URL .. "/api/1/version") + if not err then + local ver = decode_json(raw) + if ver then + local sn = pick(ver, "serial_number", "serialNumber") + if sn and sn ~= "" then host.set_sn(tostring(sn)) end + local part = pick(ver, "part_number", "partNumber") + if part and part ~= "" then + host.set_model("Wall Connector " .. tostring(part)) + end + end + end + host.log("info", "Tesla Wall Connector: polling " .. BASE_URL) +end + +function driver_poll() + if not BASE_URL then return 10000 end + local raw, err = host.http_get(BASE_URL .. "/api/1/vitals") + if err then + host.log("warn", "Tesla Wall Connector: vitals failed") + return 10000 + end + local vitals, derr = decode_json(raw) + if derr or type(vitals) ~= "table" then + host.log("warn", "Tesla Wall Connector: vitals decode failed") + return 10000 + end + + local state = as_number(vitals.evse_state) or EVSE_IDLE + local power_w = charge_power(vitals) + local session_wh = as_number(vitals.session_energy_wh) or 0 + local connected = is_connected(state, vitals) + local charging = is_charging(state, vitals) + local phases = active_phases(vitals) + + host.emit("ev", { + w = power_w, + connected = connected, + charging = charging, + session_wh = session_wh, + phases = phases, + evse_state = state, + }) + + local i1 = as_number(vitals.currentA_a) + if i1 then host.emit_metric("ev_l1_a", i1) end + local v1 = as_number(vitals.voltageA_v) + if v1 then host.emit_metric("ev_l1_v", v1) end + host.emit_metric("ev_power_w", power_w) + host.emit_metric("evse_state", state) + + return 5000 +end + +function driver_command(action, power_w, cmd) + if action == "init" or action == "deinit" then + return true + end + if action == "ev_set_current" or action == "ev_pause" + or action == "ev_resume" or action == "ev_start" then + if not warned_readonly then + host.log("warn", + "Tesla Wall Connector has no local current setpoint; " .. + "steer a Tesla through tesla_vehicle") + warned_readonly = true + end + return true + end + host.log("warn", "Tesla Wall Connector: unknown action " .. tostring(action)) + return false +end + +function driver_default_mode() + -- Observation-only: the box keeps charging at the car's last request. +end + +function driver_cleanup() + BASE_URL = nil +end diff --git a/go/internal/evcloud/tesla_wc.go b/go/internal/evcloud/tesla_wc.go new file mode 100644 index 000000000..af57c5d33 --- /dev/null +++ b/go/internal/evcloud/tesla_wc.go @@ -0,0 +1,156 @@ +package evcloud + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// teslaWCDefaultTimeout bounds the wizard probe so a missing LAN host +// cannot hold the request goroutine. 15 s matches Easee/Zaptec. +const teslaWCDefaultTimeout = 15 * time.Second + +func init() { Register("tesla-wc", NewTeslaWC()) } + +// TeslaWC implements Provider for a Tesla Wall Connector Gen 3 on the +// local network. The box exposes an undocumented but widely documented +// HTTP API (GET /api/1/vitals, /api/1/version) with no authentication. +// Current limits are not writable on the wall connector; control stays +// with tesla_vehicle when a Tesla is plugged in. +type TeslaWC struct { + client *http.Client + baseURL string // test override; production reads cfg.HTTP.BaseURL +} + +// NewTeslaWC builds a Tesla Wall Connector provider with the standard timeout. +func NewTeslaWC() *TeslaWC { + return &TeslaWC{client: &http.Client{Timeout: teslaWCDefaultTimeout}} +} + +// WithHTTPClient returns a copy using the supplied client. +func (t *TeslaWC) WithHTTPClient(c *http.Client) *TeslaWC { + cp := *t + cp.client = c + return &cp +} + +// WithBaseURL returns a copy pointed at the given origin (no trailing slash). +func (t *TeslaWC) WithBaseURL(u string) *TeslaWC { + cp := *t + cp.baseURL = strings.TrimRight(u, "/") + return &cp +} + +// Describe is the wizard's hook for a local HTTP form (host, no auth). +func (t *TeslaWC) Describe() Descriptor { + return Descriptor{ + Name: "tesla-wc", + Label: "Tesla Wall Connector", + Transport: TransportHTTP, + NeedsAuth: false, + LuaDriver: "drivers/tesla_wall_connector.lua", + } +} + +// ListChargers probes the configured LAN origin and returns the wall +// connector's serial from GET /api/1/version. A vitals-only box that +// answers without a version document still lists as one charger. +func (t *TeslaWC) ListChargers(cfg *config.EVCharger) ([]Charger, error) { + if cfg == nil { + return nil, errors.New("tesla-wc: nil config") + } + base := t.baseURL + if cfg.HTTP != nil && cfg.HTTP.BaseURL != "" { + base = teslaWCNormalizeBase(cfg.HTTP.BaseURL) + } + if base == "" { + return nil, errors.New("tesla-wc: http.base_url required") + } + + serial, name, err := t.readIdentity(base) + if err != nil { + return nil, err + } + if serial == "" { + serial = "tesla-wc" + } + if name == "" { + name = "Tesla Wall Connector" + } + return []Charger{{ID: serial, Name: name}}, nil +} + +func (t *TeslaWC) readIdentity(base string) (serial, name string, err error) { + ver, verr := t.getJSON(base + "/api/1/version") + if verr == nil { + serial = stringField(ver, "serial_number", "serialNumber") + part := stringField(ver, "part_number", "partNumber") + if part != "" { + name = "Tesla Wall Connector " + part + } + if serial != "" { + return serial, name, nil + } + } + // Version is missing on some firmware; vitals still proves the box. + if _, vitalsErr := t.getJSON(base + "/api/1/vitals"); vitalsErr != nil { + if verr != nil { + return "", "", fmt.Errorf("tesla-wc: version: %w", verr) + } + return "", "", fmt.Errorf("tesla-wc: vitals: %w", vitalsErr) + } + return serial, name, nil +} + +func (t *TeslaWC) getJSON(url string) (map[string]any, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := t.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return out, nil +} + +func teslaWCNormalizeBase(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimRight(s, "/") + if s == "" { + return "" + } + if !strings.Contains(s, "://") { + s = "http://" + s + } + return s +} + +func stringField(m map[string]any, keys ...string) string { + if m == nil { + return "" + } + for _, k := range keys { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + } + return "" +} diff --git a/go/internal/evcloud/tesla_wc_test.go b/go/internal/evcloud/tesla_wc_test.go new file mode 100644 index 000000000..c7ce87ae9 --- /dev/null +++ b/go/internal/evcloud/tesla_wc_test.go @@ -0,0 +1,141 @@ +package evcloud + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +func TestTeslaWCListChargers(t *testing.T) { + var versionHits, vitalsHits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/1/version": + versionHits++ + _ = json.NewEncoder(w).Encode(map[string]string{ + "serial_number": "TWCabc123", + "part_number": "1529455-02-J", + "firmware_version": "22.36.1", + }) + case "/api/1/vitals": + vitalsHits++ + _ = json.NewEncoder(w).Encode(map[string]any{"evse_state": 1}) + default: + http.Error(w, "unknown", http.StatusNotFound) + } + })) + defer srv.Close() + + p := NewTeslaWC().WithHTTPClient(srv.Client()) + got, err := p.ListChargers(&config.EVCharger{ + Provider: "tesla-wc", + HTTP: &config.EVChargerHTTP{BaseURL: srv.URL}, + }) + if err != nil { + t.Fatalf("ListChargers: %v", err) + } + if len(got) != 1 { + t.Fatalf("chargers: got %d, want 1", len(got)) + } + if got[0].ID != "TWCabc123" { + t.Errorf("id: got %q, want TWCabc123", got[0].ID) + } + if !strings.Contains(got[0].Name, "1529455-02-J") { + t.Errorf("name: got %q, want part number", got[0].Name) + } + if versionHits != 1 { + t.Errorf("version hits=%d, want 1", versionHits) + } + if vitalsHits != 0 { + t.Errorf("vitals should not be called when version has a serial, hits=%d", vitalsHits) + } +} + +func TestTeslaWCListChargersVitalsFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/1/version" { + http.Error(w, "no version", http.StatusNotFound) + return + } + if r.URL.Path == "/api/1/vitals" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "vehicle_connected": false, + "evse_state": 1, + }) + return + } + http.Error(w, "unknown", http.StatusNotFound) + })) + defer srv.Close() + + p := NewTeslaWC().WithHTTPClient(srv.Client()).WithBaseURL(srv.URL) + got, err := p.ListChargers(&config.EVCharger{Provider: "tesla-wc"}) + if err != nil { + t.Fatalf("ListChargers: %v", err) + } + if len(got) != 1 || got[0].ID != "tesla-wc" { + t.Fatalf("got %+v, want fallback id tesla-wc", got) + } +} + +func TestTeslaWCListChargersRequiresBaseURL(t *testing.T) { + _, err := NewTeslaWC().ListChargers(&config.EVCharger{Provider: "tesla-wc"}) + if err == nil || !strings.Contains(err.Error(), "base_url") { + t.Fatalf("expected base_url error, got %v", err) + } +} + +func TestTeslaWCNormalizeBareHost(t *testing.T) { + if got := teslaWCNormalizeBase("192.168.1.50"); got != "http://192.168.1.50" { + t.Errorf("bare host: got %q", got) + } + if got := teslaWCNormalizeBase("http://192.168.1.50/"); got != "http://192.168.1.50" { + t.Errorf("slash: got %q", got) + } +} + +func TestTeslaWCTimeoutBounded(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 + }) + + p := NewTeslaWC().WithHTTPClient(&http.Client{Timeout: 200 * time.Millisecond}).WithBaseURL(srv.URL) + done := make(chan error, 1) + go func() { + _, err := p.ListChargers(&config.EVCharger{Provider: "tesla-wc"}) + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("expected timeout error") + } + case <-time.After(2 * time.Second): + t.Fatal("ListChargers did not return within 2s") + } +} + +func TestTeslaWCDescribe(t *testing.T) { + d := NewTeslaWC().Describe() + if d.Name != "tesla-wc" || d.Label != "Tesla Wall Connector" { + t.Errorf("name/label: %+v", d) + } + if d.Transport != TransportHTTP { + t.Errorf("transport: %s", d.Transport) + } + if d.NeedsAuth { + t.Error("local wall connector must not require auth") + } + if d.LuaDriver != "drivers/tesla_wall_connector.lua" { + t.Errorf("lua: %q", d.LuaDriver) + } +} diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 6a8cfa4f0..7f3cc8a72 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -1761,7 +1761,8 @@ driver.config = { client_id: "", client_secret: "" }; } else if (entryCaps.indexOf("meter") >= 0 || entryCaps.indexOf("pv") >= 0 || - entryCaps.indexOf("battery") >= 0) { + entryCaps.indexOf("battery") >= 0 || + (entryCaps.indexOf("ev") >= 0 && hosts.length === 0)) { // Local-HTTP meter / PV / battery driver without a canned // hostname in connection_defaults (typical for generic // ESPHome / DSMR firmwares — every install picks its own diff --git a/web/settings/tabs/ev.js b/web/settings/tabs/ev.js index 07d8e31a6..e7ee57824 100644 --- a/web/settings/tabs/ev.js +++ b/web/settings/tabs/ev.js @@ -12,10 +12,11 @@ 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 (!config.ev_charger.http) config.ev_charger.http = {}; // 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) { + if (!config.ev_charger.provider && config.drivers) { for (var di = 0; di < config.drivers.length; di++) { var drv = config.drivers[di]; if ((drv.name === "easee" || drv.name === "zaptec") && drv.config) { @@ -25,6 +26,12 @@ config.ev_charger.serial = drv.config.serial || ""; break; } + if ((drv.name === "tesla-wc" || (drv.lua || "").indexOf("tesla_wall_connector") >= 0) && drv.config) { + config.ev_charger.provider = "tesla-wc"; + config.ev_charger.http.base_url = drv.config.base_url || + (drv.config.host ? ("http://" + drv.config.host) : ""); + break; + } } } var evHasPassword = !!getByPath(config, "ev_charger.password", ""); @@ -33,23 +40,35 @@ : '⚠ No credentials saved'; return '
checking…
' + '
EV Charger' + - 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.") + - '' + - '' + - '
' + credsBadge + '
' + - field("Charger serial", "ev_charger.serial", "text", "", - "Serial number of the charger. Leave empty to auto-detect the first charger on the account.") + + selectField("Provider", "ev_charger.provider", ["easee", "zaptec", "tesla-wc"], "easee", + "Easee and Zaptec use a cloud account. Tesla Wall Connector is local HTTP on the LAN.") + + (getByPath(config, "ev_charger.provider", "easee") === "tesla-wc" + ? field("Host", "ev_charger.http.base_url", "text", "http://192.168.1.50", + "LAN address of the Wall Connector. No Tesla account. Current is set by the car or the Tesla app.") + : field("Email", "ev_charger.email", "text", "", + "Account email for the charger cloud service.") + + '' + + '' + + '
' + credsBadge + '
' + + field("Charger serial", "ev_charger.serial", "text", "", + "Serial number of the charger. Leave empty to auto-detect the first charger on the account.")) + '
' + '

' + - '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.' + + (getByPath(config, "ev_charger.provider", "easee") === "tesla-wc" + ? 'Tesla Wall Connector Gen 3 is polled on the LAN. It reports plug state and power; it cannot take a current setpoint. Pair tesla_vehicle to steer a Tesla.' + : '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.') + '

'; }, after: function (ctx) { var bodyEl = ctx.bodyEl; + var providerSel = bodyEl.querySelector('[data-path="ev_charger.provider"]'); + if (providerSel) { + providerSel.addEventListener("change", function () { + ctx.captureCurrentTab(); + ctx.renderTab("ev"); + }); + } var evHasPassword = !!ctx.getByPath(ctx.config, "ev_charger.password", ""); var pwInput = bodyEl.querySelector('[data-path="ev_charger.password"]'); if (pwInput) { @@ -88,7 +107,9 @@ var charger = null; for (var i = 0; i < drivers.length; i++) { var n = (drivers[i].name || "").toLowerCase(); - if (n.indexOf("easee") >= 0 || n.indexOf("zaptec") >= 0) { + if (n.indexOf("easee") >= 0 || n.indexOf("zaptec") >= 0 || + n.indexOf("tesla-wc") >= 0 || n.indexOf("tesla_wall") >= 0 || + n.indexOf("tesla-wall") >= 0) { charger = drivers[i]; break; } diff --git a/web/setup.html b/web/setup.html index d244ad6c0..8c6348579 100644 --- a/web/setup.html +++ b/web/setup.html @@ -737,6 +737,13 @@

EV Charger

+