Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tesla-wall-connector.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 16 additions & 3 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,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

Expand Down Expand Up @@ -539,6 +539,19 @@ 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.
Comment on lines +544 to +545

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject a Tesla configuration without a host

The setup wizard can select Tesla and finish without entering or probing a host; buildConfig then emits only {provider: "tesla-wc"}, and this explicit allowance lets the final /api/config save succeed. Both ListChargers and the Lua driver's initialization require an address, so the persisted charger is unusable. Require a nonempty http.base_url on the final save or enforce the field in the wizard rather than accepting this provider-only configuration.

Useful? React with 👍 / 👎.

if e.Modbus != nil {
return errors.New("ev_charger.modbus: not valid for provider tesla-wc (HTTP transport)")
}
// Switching from Easee/Zaptec leaves username/password on the
// posted document, and POST /api/config restores ev_charger_password
// from state.db. Drop them so the provider switch can save.
e.Username = ""
e.Password = ""
e.EmailLegacy = ""
case "ctek":
if e.Modbus == nil || e.Modbus.Host == "" {
return errors.New("ev_charger.modbus.host: required for provider ctek")
Expand All @@ -556,7 +569,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
}
Expand Down
47 changes: 47 additions & 0 deletions go/internal/config/evcharger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,53 @@ 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"},
{"leftover username stripped", EVCharger{Provider: "tesla-wc", HTTP: &EVChargerHTTP{BaseURL: "http://10.0.0.8"}, Username: "u"}, ""},
{"leftover password stripped", EVCharger{Provider: "tesla-wc", HTTP: &EVChargerHTTP{BaseURL: "http://10.0.0.8"}, Password: "p"}, ""},
}
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 TestEVChargerValidateTeslaWCStripsCloudCreds(t *testing.T) {
e := EVCharger{
Provider: "tesla-wc",
Username: "old@example.com",
Password: "easee-secret",
EmailLegacy: "old@example.com",
HTTP: &EVChargerHTTP{BaseURL: "http://192.168.1.50"},
}
if err := e.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
if e.Username != "" || e.Password != "" || e.EmailLegacy != "" {
t.Fatalf("leftover cloud creds after tesla-wc validate: %+v", e)
}
}

func TestEVChargerValidateCTek(t *testing.T) {
cases := []struct {
name string
Expand Down
Loading