From c1fac30171e25aa3fbea8e37173ffc433e76fb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20DAVID?= Date: Sun, 9 Aug 2026 13:39:24 +0200 Subject: [PATCH] Tesla BLE: handle sleeping vehicles with vehicle-api charger (#32615) --- charger/vehicle-api.go | 41 ++++++++++++++++++++++++++++--- charger/vehicle-api_test.go | 49 +++++++++++++++++++++++++++++++++++++ util/request/functions.go | 15 +++++++++--- 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 charger/vehicle-api_test.go diff --git a/charger/vehicle-api.go b/charger/vehicle-api.go index 404cbb621..61fa6c230 100644 --- a/charger/vehicle-api.go +++ b/charger/vehicle-api.go @@ -1,13 +1,17 @@ package charger import ( + "encoding/json" "errors" "math" + "net/http" + "strings" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" ) // VehicleApi is a charger implementation that uses the vehicle api @@ -49,6 +53,28 @@ func NewVehicleApiFromConfig(other map[string]any) (api.Charger, error) { return c, nil } +// asleep maps a vehicle api's sleeping response to api.ErrAsleep so the loadpoint +// can trigger the existing wake-up logic. Proxies like TeslaBleHttpProxy answer +// HTTP 503 with a json body stating the reason. +func asleep(err error) error { + var se *request.StatusError + if !errors.As(err, &se) || !se.HasStatus(http.StatusServiceUnavailable) { + return err + } + + var res struct { + Response struct { + Reason string + } + } + + if json.Unmarshal(se.Body(), &res) == nil && strings.Contains(strings.ToLower(res.Response.Reason), "sleep") { + return api.ErrAsleep + } + + return err +} + // isVehicleAtHome checks if the vehicle is within the geofence (if enabled) func (c *VehicleApi) isVehicleAtHome(vehicle api.Vehicle) (bool, error) { if !c.geofenceEnabled { @@ -62,7 +88,7 @@ func (c *VehicleApi) isVehicleAtHome(vehicle api.Vehicle) (bool, error) { lat, lon, err := v.Position() if err != nil { - return false, err + return false, asleep(err) } return c.distance(lat, lon) <= c.radius, nil @@ -82,6 +108,11 @@ func (c *VehicleApi) Status() (api.ChargeStatus, error) { // Check if vehicle is at the charger (trying to use geofencing) atHome, err := c.isVehicleAtHome(vehicle) if err != nil { + // position unknown while asleep: report connected so the loadpoint wakes the + // vehicle, the geofence is re-evaluated once it responds again + if errors.Is(err, api.ErrAsleep) { + return api.StatusB, nil + } return api.StatusA, err } @@ -104,6 +135,10 @@ func (c *VehicleApi) Status() (api.ChargeStatus, error) { status, err := v.Status() if err != nil { + // asleep: report connected so the loadpoint's setLimit path can wake the vehicle + if errors.Is(asleep(err), api.ErrAsleep) { + return api.StatusB, nil + } return api.StatusNone, err } @@ -142,7 +177,7 @@ func (c *VehicleApi) Enable(enable bool) error { } if err := v.ChargeEnable(enable); err != nil { - return err + return asleep(err) } c.enabled = enable @@ -164,7 +199,7 @@ func (c *VehicleApi) MaxCurrent(current int64) error { return nil } - return v.MaxCurrent(current) + return asleep(v.MaxCurrent(current)) } var _ api.Resurrector = (*VehicleApi)(nil) diff --git a/charger/vehicle-api_test.go b/charger/vehicle-api_test.go new file mode 100644 index 000000000..06a051688 --- /dev/null +++ b/charger/vehicle-api_test.go @@ -0,0 +1,49 @@ +package charger + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util/request" + "github.com/stretchr/testify/require" +) + +// statusErr builds the error a plugin returns for the given response +func statusErr(t *testing.T, code int, body string) error { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, "http://vehicle.test", nil) + require.NoError(t, err) + + resp := &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } + + _, err = request.ReadBody(resp) + require.Error(t, err) + + return err +} + +func TestAsleep(t *testing.T) { + sleeping := `{"response":{"result":false,"reason":"vehicle is sleeping"}}` + + // TeslaBleHttpProxy sleeping response + require.ErrorIs(t, asleep(statusErr(t, http.StatusServiceUnavailable, sleeping)), api.ErrAsleep) + + // 503 for any other reason is not asleep + other := statusErr(t, http.StatusServiceUnavailable, `{"response":{"reason":"vehicle is offline"}}`) + require.NotErrorIs(t, asleep(other), api.ErrAsleep) + + // non-503 with sleeping body is not asleep + require.NotErrorIs(t, asleep(statusErr(t, http.StatusInternalServerError, sleeping)), api.ErrAsleep) + + // non-json body and nil error pass through + require.NotErrorIs(t, asleep(statusErr(t, http.StatusServiceUnavailable, "sleeping")), api.ErrAsleep) + require.NoError(t, asleep(nil)) +} diff --git a/util/request/functions.go b/util/request/functions.go index 8826d8f90..d3fc4fbf0 100644 --- a/util/request/functions.go +++ b/util/request/functions.go @@ -44,6 +44,7 @@ var ( // StatusError indicates unsuccessful http response type StatusError struct { resp *http.Response + body []byte } func NewStatusError(resp *http.Response) *StatusError { @@ -60,6 +61,11 @@ func (e *StatusError) Response() *http.Response { return e.resp } +// Body returns the error response body if it has been read +func (e *StatusError) Body() []byte { + return e.body +} + // StatusCode returns the response's status code func (e *StatusError) StatusCode() int { return e.resp.StatusCode @@ -82,12 +88,13 @@ func ResponseError(resp *http.Response) error { func ReadBody(resp *http.Response) ([]byte, error) { defer resp.Body.Close() - if err := ResponseError(resp); err != nil { - b, _ := io.ReadAll(resp.Body) - return b, err + b, err := io.ReadAll(resp.Body) + + // keep the body on the error so callers can inspect the device's error response + if c := resp.StatusCode; c < 200 || c >= 300 { + return b, backoff.Permanent(&StatusError{resp: resp, body: b}) } - b, err := io.ReadAll(resp.Body) if err != nil { return []byte{}, err }