From 9f87106b40e5ffd1d0659a27ca6ecb645de03ff8 Mon Sep 17 00:00:00 2001 From: fanatix <43752712+djfanatix@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:14:58 +0100 Subject: [PATCH] Add Tessie (#19881) --- charger/tessie.go | 273 +++++++++++++++++++++++ templates/definition/charger/tessie.yaml | 28 +++ templates/definition/vehicle/tessie.yaml | 2 +- 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 charger/tessie.go create mode 100644 templates/definition/charger/tessie.yaml diff --git a/charger/tessie.go b/charger/tessie.go new file mode 100644 index 000000000..28f07ca34 --- /dev/null +++ b/charger/tessie.go @@ -0,0 +1,273 @@ +package charger + +import ( + "context" + "fmt" + "net/http" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "golang.org/x/oauth2" +) + +type Tessie struct { + log *util.Logger + client *request.Helper + Vin string + Location string + Maxcurrent int64 + chargingStartedAfterLeavingGeofence bool +} + +func init() { + registry.Add("tessie", NewTessieFromConfig) +} + +func NewTessieFromConfig(other map[string]interface{}) (api.Charger, error) { + cc := struct { + Vin string + Token string + Location string + Maxcurrent int64 + }{} + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("tessie") + + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cc.Token}) + oauthClient := oauth2.NewClient(context.Background(), tokenSource) + + client := request.NewHelper(log) + client.Client = oauthClient + + t := &Tessie{ + log: log, + client: client, + Vin: cc.Vin, + Location: cc.Location, + Maxcurrent: cc.Maxcurrent, + } + + return t, nil +} + +func (t *Tessie) Enabled() (bool, error) { + locationName, err := t.getLocationName() + if err != nil { + return false, err + } + locationMatch := t.Location == locationName || t.Location == "always" + + if !locationMatch { + return false, nil + } + + url := fmt.Sprintf("https://api.tessie.com/%s/state?values=charge_state", t.Vin) + req, err := request.New(http.MethodGet, url, nil, nil) + if err != nil { + return false, err + } + + var res struct { + ChargeState struct { + ChargingState string `json:"charging_state"` + } `json:"charge_state"` + } + + if err := t.client.DoJSON(req, &res); err != nil { + return false, err + } + + return res.ChargeState.ChargingState == "Charging", nil +} + +func (t *Tessie) Enable(enable bool) error { + command := "stop_charging" + if enable { + command = "start_charging" + } + + url := fmt.Sprintf("https://api.tessie.com/%s/command/%s?retry_duration=40&wait_for_completion=true", t.Vin, command) + req, err := request.New(http.MethodPost, url, nil, nil) + if err != nil { + return err + } + _, err = t.client.Do(req) + if err != nil { + return err + } + + if !enable { + err = t.MaxCurrent(32) + if err != nil { + return err + } + } + + return nil +} + +func (t *Tessie) MaxCurrent(current int64) error { + locationName, err := t.getLocationName() + if err != nil { + return err + } + locationMatch := t.Location == locationName || t.Location == "always" + + if !locationMatch { + return nil + } + url := fmt.Sprintf("https://api.tessie.com/%s/command/set_charging_amps?retry_duration=40&wait_for_completion=true&s=%d", t.Vin, current) + req, err := request.New(http.MethodPost, url, nil, nil) + if err != nil { + return err + } + _, err = t.client.Do(req) + return err +} + +func (t *Tessie) Status() (api.ChargeStatus, error) { + locationName, err := t.getLocationName() + if err != nil { + return api.StatusNone, err + } + + url := fmt.Sprintf("https://api.tessie.com/%s/state?values=charge_state", t.Vin) + req, err := request.New(http.MethodGet, url, nil, nil) + if err != nil { + return api.StatusNone, err + } + + var res struct { + ChargeState struct { + ChargingState string `json:"charging_state"` + ChargePortDoorOpen bool `json:"charge_port_door_open"` + } `json:"charge_state"` + } + + if err := t.client.DoJSON(req, &res); err != nil { + return api.StatusNone, err + } + + locationMatch := t.Location == locationName || t.Location == "always" + + if !locationMatch { + if !t.chargingStartedAfterLeavingGeofence { + if err := t.startCharging(); err != nil { + t.log.ERROR.Printf("Failed to start charging after leaving geofence: %v", err) + } else { + t.chargingStartedAfterLeavingGeofence = true + } + } + return api.StatusA, nil + } else { + t.chargingStartedAfterLeavingGeofence = false + } + + switch res.ChargeState.ChargingState { + case "Charging": + return api.StatusC, nil + case "Complete": + return api.StatusA, nil + default: + if res.ChargeState.ChargePortDoorOpen { + return api.StatusB, nil + } + return api.StatusA, nil + } +} + +func (t *Tessie) CurrentPower() (float64, error) { + locationName, err := t.getLocationName() + if err != nil { + return 0, err + } + locationMatch := t.Location == locationName || t.Location == "always" + + if !locationMatch { + return 0, nil + } + + url := fmt.Sprintf("https://api.tessie.com/%s/state?values=charge_state", t.Vin) + req, err := request.New(http.MethodGet, url, nil, nil) + if err != nil { + return 0, err + } + + var res struct { + ChargeState struct { + ChargerPower float64 `json:"charger_power"` + } `json:"charge_state"` + } + + if err := t.client.DoJSON(req, &res); err != nil { + return 0, err + } + + return res.ChargeState.ChargerPower * 1000, nil +} + +func (t *Tessie) ChargedEnergy() (float64, error) { + locationName, err := t.getLocationName() + if err != nil { + return 0, err + } + locationMatch := t.Location == locationName || t.Location == "always" + + if !locationMatch { + return 0, nil + } + + url := fmt.Sprintf("https://api.tessie.com/%s/state?values=charge_state", t.Vin) + req, err := request.New(http.MethodGet, url, nil, nil) + if err != nil { + return 0, err + } + + var res struct { + ChargeState struct { + ChargeEnergyAdded float64 `json:"charge_energy_added"` + } `json:"charge_state"` + } + + if err := t.client.DoJSON(req, &res); err != nil { + return 0, err + } + + return res.ChargeState.ChargeEnergyAdded, nil +} + +func (t *Tessie) startCharging() error { + url := fmt.Sprintf("https://api.tessie.com/%s/command/start_charging?retry_duration=40&wait_for_completion=true", t.Vin) + req, err := request.New(http.MethodPost, url, nil, nil) + if err != nil { + return err + } + _, err = t.client.Do(req) + return err +} + +func (t *Tessie) getLocationName() (string, error) { + url := fmt.Sprintf("https://api.tessie.com/%s/location", t.Vin) + req, err := request.New(http.MethodGet, url, nil, nil) + if err != nil { + return "", err + } + + var res struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Address string `json:"address"` + SavedLocation string `json:"saved_location"` + } + + if err := t.client.DoJSON(req, &res); err != nil { + return "", err + } + + return res.SavedLocation, nil +} diff --git a/templates/definition/charger/tessie.yaml b/templates/definition/charger/tessie.yaml new file mode 100644 index 000000000..5c9d30f53 --- /dev/null +++ b/templates/definition/charger/tessie.yaml @@ -0,0 +1,28 @@ +template: tessie +group: generic +products: + - description: + generic: Tessie +requirements: + description: + en: Charger connected via the Tessie API. Allows control of charging state and configuration of maximum current. + de: Ladegerät, das über die Tessie-API verbunden ist. Ermöglicht die Steuerung des Ladezustands und die Konfiguration des maximalen Stroms. +params: + - name: vin + required: true + - name: token + description: + de: Tessie API Token + en: Tessie API Token + required: true + - name: location + description: + de: Definieren Sie einen Ort, an dem das Tessie-Ladegerät funktioniert (always = immer, kein Geofence) oder ein benutzerdefinierter Ort (genauer Name), wie in Tessie definiert (Case sensitive) + en: Define a location where the Tessie charger will work (always = always, no geofence) or a custom location (exact name) as defined in Tessie (Case sensitive) + example: "tessiehome" + required: true +render: | + type: tessie + vin: {{ .vin }} + token: {{ .token }} + location: {{ .location }} diff --git a/templates/definition/vehicle/tessie.yaml b/templates/definition/vehicle/tessie.yaml index 366587135..84add1266 100644 --- a/templates/definition/vehicle/tessie.yaml +++ b/templates/definition/vehicle/tessie.yaml @@ -86,7 +86,7 @@ render: | method: POST wakeup: source: http - uri: https://api.tessie.com/{{ .vin }}/wake + uri: https://api.tessie.com/{{ .vin }}/command/wake headers: Authorization: Bearer {{ .token }} method: POST