From 709fc827e21940b25e63005415dd416c810d8b47 Mon Sep 17 00:00:00 2001 From: Sam Feyaerts Date: Thu, 11 Dec 2025 21:26:30 +0100 Subject: [PATCH] Renault: add SocLimiter (#25757) Co-authored-by: andig --- vehicle/renault.go | 4 +- vehicle/renault/kamereon/api.go | 207 +++++++++++++++++------------- vehicle/renault/kamereon/auth.go | 76 +++++++++++ vehicle/renault/kamereon/types.go | 53 +++++--- vehicle/renault/provider.go | 111 ++++++++++------ 5 files changed, 306 insertions(+), 145 deletions(-) create mode 100644 vehicle/renault/kamereon/auth.go diff --git a/vehicle/renault.go b/vehicle/renault.go index ff7f14f1c..6a0b140e0 100644 --- a/vehicle/renault.go +++ b/vehicle/renault.go @@ -72,12 +72,12 @@ func NewRenaultDaciaFromConfig(brand string, other map[string]any) (api.Vehicle, return nil, err } - api := kamereon.New(log, keys.Kamereon, identity, func() error { + api := kamereon.NewAPI(log, keys.Kamereon, identity, func() error { return identity.Login(cc.User, cc.Password) }) api.Client.Timeout = cc.Timeout - accountID, err := api.Person(identity.PersonID, brand) + accountID, err := api.AccountID(identity.PersonID, brand) if err != nil { return nil, err } diff --git a/vehicle/renault/kamereon/api.go b/vehicle/renault/kamereon/api.go index 9ca26d228..b65486ec6 100644 --- a/vehicle/renault/kamereon/api.go +++ b/vehicle/renault/kamereon/api.go @@ -1,12 +1,9 @@ package kamereon import ( - "bytes" "errors" "fmt" - "io" "net/http" - "net/url" "strings" "github.com/evcc-io/evcc/util" @@ -16,8 +13,9 @@ import ( ) const ( - ActionStart = "start" - ActionStop = "stop" + ActionStart = "start" + ActionStop = "stop" + ActionResume = "resume" ) type API struct { @@ -27,70 +25,43 @@ type API struct { login func() error } -func New(log *util.Logger, keys keys.ConfigServer, identity *gigya.Identity, login func() error) *API { - return &API{ +func NewAPI(log *util.Logger, keys keys.ConfigServer, identity *gigya.Identity, login func() error) *API { + v := &API{ Helper: request.NewHelper(log), keys: keys, identity: identity, login: login, } + + v.Client.Transport = &AuthDecorator{ + Login: v.login, + Keys: v.keys, + Identity: v.identity, + Base: v.Client.Transport, + } + + return v } -func (v *API) request_(uri string, body io.Reader) (Response, error) { - params := url.Values{"country": []string{"DE"}} - headers := map[string]string{ - "content-type": "application/vnd.api+json", - "x-gigya-id_token": v.identity.Token, - "apikey": v.keys.APIKey, - } - - method := http.MethodGet - if body != nil { - method = http.MethodPost - } - - var res Response - req, err := request.New(method, uri+"?"+params.Encode(), body, headers) - if err == nil { - err = v.DoJSON(req, &res) - } - - return res, err -} - -func (v *API) request(uri string, body io.Reader) (Response, error) { - if body != nil { - b, err := io.ReadAll(body) - if err != nil { - return Response{}, err - } - // read from buffer - body = bytes.NewReader(b) - } - - res, err := v.request_(uri, body) - // repeat auth if error - if err != nil { - if err = v.login(); err == nil { - if body != nil { - // rewind body - body.(*bytes.Reader).Seek(0, io.SeekStart) - } - res, err = v.request_(uri, body) - } - } - - return res, err -} - -func (v *API) Person(personID, brand string) (string, error) { +func (v *API) Accounts(personID string) ([]Account, error) { uri := fmt.Sprintf("%s/commerce/v1/persons/%s", v.keys.Target, personID) - res, err := v.request(uri, nil) + + var res struct { + Accounts []Account `json:"accounts"` + } + err := v.GetJSON(uri, &res) + + return res.Accounts, err +} + +func (v *API) AccountID(personID, brand string) (string, error) { + accounts, err := v.Accounts(personID) + if err != nil { return "", err } - for _, account := range res.Accounts { + for _, account := range accounts { if strings.Contains(strings.ToLower(account.AccountType), strings.ToLower(brand)) { return account.AccountID, nil } @@ -101,47 +72,90 @@ func (v *API) Person(personID, brand string) (string, error) { func (v *API) Vehicles(accountID string) ([]Vehicle, error) { uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/vehicles", v.keys.Target, accountID) - res, err := v.request(uri, nil) + + var res struct { + VehicleLinks []Vehicle `json:"vehicleLinks"` + } + err := v.GetJSON(uri, &res) + return res.VehicleLinks, err } -// Battery provides battery-status api response -func (v *API) Battery(accountID string, vin string) (Response, error) { +func (v *API) BatteryStatus(accountID string, vin string) (BatteryStatus, error) { uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v2/cars/%s/battery-status", v.keys.Target, accountID, vin) - return v.request(uri, nil) + + var res DataEnvelope[BatteryStatus] + err := v.GetJSON(uri, &res) + + return res.Data.Attributes, err } -// Hvac provides hvac-status api response -func (v *API) Hvac(accountID string, vin string) (Response, error) { +func (v *API) HvacStatus(accountID string, vin string) (HvacStatus, error) { uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/hvac-status", v.keys.Target, accountID, vin) - return v.request(uri, nil) + + var res DataEnvelope[HvacStatus] + err := v.GetJSON(uri, &res) + + return res.Data.Attributes, err } -// Cockpit provides cockpit api response -func (v *API) Cockpit(accountID string, vin string) (Response, error) { +func (v *API) Cockpit(accountID string, vin string) (Cockpit, error) { uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/cockpit", v.keys.Target, accountID, vin) - return v.request(uri, nil) + + var res DataEnvelope[Cockpit] + err := v.GetJSON(uri, &res) + + return res.Data.Attributes, err } -func (v *API) WakeUp(accountID string, vin string) (Response, error) { +func (v *API) SocLevels(accountID string, vin string) (SocLevels, error) { + uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kcm/v1/vehicles/%s/ev/soc-levels", v.keys.Target, accountID, vin) + + var res SocLevels + err := v.GetJSON(uri, &res) + + return res, err +} + +func (v *API) Position(accountID string, vin string) (Position, error) { + uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/location", v.keys.Target, accountID, vin) + + var res DataEnvelope[Position] + err := v.GetJSON(uri, &res) + + return res.Data.Attributes, err +} + +func (v *API) WakeUp(accountID string, vin string) (ChargeAction, error) { uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kcm/v1/vehicles/%s/charge/pause-resume", v.keys.Target, accountID, vin) - data := map[string]any{ - "data": map[string]any{ - "type": "ChargePauseResume", - "attributes": map[string]any{ - "action": "resume", + reqBody := map[string]any{ + "data": ChargeAction{ + Type: "ChargePauseResume", + Attributes: ChargeActionAttributes{ + Action: ActionResume, }, }, } - return v.request(uri, request.MarshalJSON(data)) + var res struct { + Data ChargeAction `json:"data"` + } + req, err := request.New(http.MethodPost, uri, request.MarshalJSON(reqBody)) + + if err != nil { + return ChargeAction{}, err + } + + err = v.DoJSON(req, &res) + + return res.Data, err } -func (v *API) WakeUpMY24(accountID string, vin string) (Response, error) { +func (v *API) WakeUpMy24(accountID string, vin string) (EvSettingsResponse, error) { uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kcm/v1/vehicles/%s/ev/settings", v.keys.Target, accountID, vin) - data := EvSettingsRequest{ + reqBody := EvSettingsRequest{ LastSettingsUpdateTimestamp: "2025-04-24T12:41:41.823Z", DelegatedActivated: false, ChargeModeRq: "SCHEDULED", @@ -151,25 +165,40 @@ func (v *API) WakeUpMY24(accountID string, vin string) (Response, error) { Programs: []any{}, } - return v.request(uri, request.MarshalJSON(data)) + req, err := request.New(http.MethodPost, uri, request.MarshalJSON(reqBody)) + + if err != nil { + return EvSettingsResponse{}, err + } + + var res EvSettingsResponse + err = v.DoJSON(req, &res) + + return res, err } -func (v *API) Position(accountID string, vin string) (Response, error) { - uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/location", v.keys.Target, accountID, vin) - return v.request(uri, nil) -} - -func (v *API) Action(accountID, action string, vin string) (Response, error) { +func (v *API) ChargeAction(accountID, action string, vin string) (ChargeAction, error) { uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/actions/charging-start", v.keys.Target, accountID, vin) - data := map[string]any{ - "data": map[string]any{ - "type": "ChargingStart", - "attributes": map[string]any{ - "action": action, + reqBody := map[string]any{ + "data": ChargeAction{ + Type: "ChargingStart", + Attributes: ChargeActionAttributes{ + Action: action, }, }, } - return v.request(uri, request.MarshalJSON(data)) + var res struct { + Data ChargeAction `json:"data"` + } + req, err := request.New(http.MethodPost, uri, request.MarshalJSON(reqBody)) + + if err != nil { + return ChargeAction{}, err + } + + err = v.DoJSON(req, &res) + + return res.Data, err } diff --git a/vehicle/renault/kamereon/auth.go b/vehicle/renault/kamereon/auth.go new file mode 100644 index 000000000..1c9e34bbd --- /dev/null +++ b/vehicle/renault/kamereon/auth.go @@ -0,0 +1,76 @@ +package kamereon + +import ( + "bytes" + "io" + "net/http" + + "github.com/evcc-io/evcc/vehicle/renault/gigya" + "github.com/evcc-io/evcc/vehicle/renault/keys" +) + +type AuthDecorator struct { + Base http.RoundTripper + Login func() error + Keys keys.ConfigServer + Identity *gigya.Identity +} + +func (rt *AuthDecorator) RoundTrip(req *http.Request) (*http.Response, error) { + // Buffer request body for potential retries + var ( + bodyBuffer []byte + err error + ) + if req.Body != nil { + bodyBuffer, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + + req.Body = io.NopCloser(bytes.NewReader(bodyBuffer)) + } + + resp, err := rt.executeRequest(req) + + if err == nil && resp != nil && resp.StatusCode == http.StatusUnauthorized { + // Drain and close response body + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + // Try reauthenticating + if err := rt.Login(); err != nil { + return nil, err + } + + // Reset request body + if bodyBuffer != nil { + req.Body = io.NopCloser(bytes.NewReader(bodyBuffer)) + } + + // Retry the request + resp, err = rt.executeRequest(req) + } + + return resp, err +} + +func (rt *AuthDecorator) executeRequest(req *http.Request) (*http.Response, error) { + // Set required headers + req.Header.Set("content-type", "application/vnd.api+json") + req.Header.Set("x-gigya-id_token", rt.Identity.Token) + req.Header.Set("apikey", rt.Keys.APIKey) + + // Set country query parameter + q := req.URL.Query() + q.Set("country", "DE") + req.URL.RawQuery = q.Encode() + + base := rt.Base + if base == nil { + base = http.DefaultTransport + } + + return base.RoundTrip(req) +} diff --git a/vehicle/renault/kamereon/types.go b/vehicle/renault/kamereon/types.go index e4b1a2414..f101836a4 100644 --- a/vehicle/renault/kamereon/types.go +++ b/vehicle/renault/kamereon/types.go @@ -5,13 +5,6 @@ import ( "strings" ) -type Response struct { - Accounts []Account // /commerce/v1/persons/%s - AccessToken string // /commerce/v1/accounts/%s/kamereon/token - VehicleLinks []Vehicle // /commerce/v1/accounts/%s/vehicles - Data Data // /commerce/v1/accounts/%s/kamereon/kca/car-adapter/vX/cars/%s/... -} - type Account struct { AccountID string AccountType string @@ -44,12 +37,7 @@ func (v *Vehicle) Available() error { return nil } -type Data struct { - Attributes attributes -} - -type attributes struct { - // battery-status +type BatteryStatus struct { Timestamp string `json:"timestamp"` ChargingStatus float32 `json:"chargingStatus"` InstantaneousPower int `json:"instantaneousPower"` @@ -61,16 +49,43 @@ type attributes struct { LastUpdateTime string `json:"lastUpdateTime"` ChargePower int `json:"chargePower"` RemainingTime *int `json:"chargingRemainingTime"` - // hvac-status +} + +type HvacStatus struct { ExternalTemperature float64 `json:"externalTemperature"` HvacStatus string `json:"hvacStatus"` - // cockpit +} + +type Cockpit struct { TotalMileage *float64 `json:"totalMileage"` - // position +} + +type SocLevels struct { + SocMin *int `json:"socMin"` + SocTarget *int `json:"socTarget"` + LastEnergyUpdateTimestamp string `json:"lastEnergyUpdateTimestamp"` +} + +type Position struct { Latitude float64 `json:"gpsLatitude"` Longitude float64 `json:"gpsLongitude"` } +type ChargeAction struct { + Type string `json:"type"` + Attributes ChargeActionAttributes `json:"attributes"` +} + +type ChargeActionAttributes struct { + Action string `json:"action"` +} + +type DataEnvelope[T any] struct { + Data struct { + Attributes T `json:"attributes"` + } `json:"data"` +} + type EvSettingsRequest struct { LastSettingsUpdateTimestamp string `json:"lastSettingsUpdateTimestamp"` DelegatedActivated bool `json:"delegatedActivated"` @@ -83,3 +98,9 @@ type EvSettingsRequest struct { PreconditioningHeatedLeftSeat bool `json:"preconditioningHeatedLeftSeat"` Programs []any `json:"programs"` } + +type EvSettingsResponse struct { + CommandId string `json:"commandId"` + Type string `json:"type"` + Status string `json:"status"` +} diff --git a/vehicle/renault/provider.go b/vehicle/renault/provider.go index e9f997368..527f67812 100644 --- a/vehicle/renault/provider.go +++ b/vehicle/renault/provider.go @@ -15,41 +15,53 @@ import ( // Provider is an api.Vehicle implementation for PSA cars type Provider struct { - batteryG func() (kamereon.Response, error) - cockpitG func() (kamereon.Response, error) - hvacG func() (kamereon.Response, error) - wakeup func() (kamereon.Response, error) - position func() (kamereon.Response, error) - action func(action string) (kamereon.Response, error) + batteryStatusG func() (kamereon.BatteryStatus, error) + cockpitG func() (kamereon.Cockpit, error) + socLevelsG func() (kamereon.SocLevels, error) + hvacG func() (kamereon.HvacStatus, error) + wakeup func() error + position func() (kamereon.Position, error) + chargeAction func(action string) (kamereon.ChargeAction, error) } // NewProvider creates a vehicle api provider func NewProvider(api *kamereon.API, accountID, vin string, wakeupMode string, cache time.Duration) *Provider { impl := &Provider{ - batteryG: util.Cached(func() (kamereon.Response, error) { - return api.Battery(accountID, vin) + batteryStatusG: util.Cached(func() (kamereon.BatteryStatus, error) { + return api.BatteryStatus(accountID, vin) }, cache), - cockpitG: util.Cached(func() (kamereon.Response, error) { + cockpitG: util.Cached(func() (kamereon.Cockpit, error) { return api.Cockpit(accountID, vin) }, cache), - hvacG: util.Cached(func() (kamereon.Response, error) { - return api.Hvac(accountID, vin) + socLevelsG: util.Cached(func() (kamereon.SocLevels, error) { + return api.SocLevels(accountID, vin) }, cache), - wakeup: func() (kamereon.Response, error) { + hvacG: util.Cached(func() (kamereon.HvacStatus, error) { + return api.HvacStatus(accountID, vin) + }, cache), + wakeup: func() error { + var err error switch wakeupMode { case "alternative": - return api.Action(accountID, kamereon.ActionStart, vin) + _, err = api.ChargeAction(accountID, kamereon.ActionStart, vin) case "MY24": - return api.WakeUpMY24(accountID, vin) + _, err = api.WakeUpMy24(accountID, vin) default: - return api.WakeUp(accountID, vin) + _, err = api.WakeUp(accountID, vin) + + // Check if default wakeup is unsupported + var se *request.StatusError + if errors.As(err, &se) && se.HasStatus(http.StatusForbidden, http.StatusNotFound, http.StatusBadGateway) { + _, err = api.WakeUpMy24(accountID, vin) + } } + return err }, - position: func() (kamereon.Response, error) { + position: func() (kamereon.Position, error) { return api.Position(accountID, vin) }, - action: func(action string) (kamereon.Response, error) { - return api.Action(accountID, action, vin) + chargeAction: func(action string) (kamereon.ChargeAction, error) { + return api.ChargeAction(accountID, action, vin) }, } return impl @@ -59,16 +71,16 @@ var _ api.Battery = (*Provider)(nil) // Soc implements the api.Vehicle interface func (v *Provider) Soc() (float64, error) { - res, err := v.batteryG() + res, err := v.batteryStatusG() if err != nil { return 0, err } - if res.Data.Attributes.BatteryLevel == nil { + if res.BatteryLevel == nil { return 0, api.ErrNotAvailable } - return float64(*res.Data.Attributes.BatteryLevel), nil + return float64(*res.BatteryLevel), nil } var _ api.ChargeState = (*Provider)(nil) @@ -77,12 +89,12 @@ var _ api.ChargeState = (*Provider)(nil) func (v *Provider) Status() (api.ChargeStatus, error) { status := api.StatusA // disconnected - res, err := v.batteryG() + res, err := v.batteryStatusG() if err == nil { - if res.Data.Attributes.PlugStatus == 1 { + if res.PlugStatus == 1 { status = api.StatusB } - if res.Data.Attributes.ChargingStatus >= 1.0 { + if res.ChargingStatus >= 1.0 { status = api.StatusC } } @@ -94,10 +106,10 @@ var _ api.VehicleRange = (*Provider)(nil) // Range implements the api.VehicleRange interface func (v *Provider) Range() (int64, error) { - res, err := v.batteryG() + res, err := v.batteryStatusG() if err == nil { - return int64(res.Data.Attributes.BatteryAutonomy), nil + return int64(res.BatteryAutonomy), nil } return 0, err @@ -112,8 +124,31 @@ func (v *Provider) Odometer() (float64, error) { return 0, err } - if res.Data.Attributes.TotalMileage != nil { - return *res.Data.Attributes.TotalMileage, nil + if res.TotalMileage != nil { + return *res.TotalMileage, nil + } + + return 0, api.ErrNotAvailable +} + +var _ api.SocLimiter = (*Provider)(nil) + +// GetLimitSoc implements the api.SocLimiter interface +func (v *Provider) GetLimitSoc() (int64, error) { + res, err := v.socLevelsG() + + // Check if endpoint is unavailable + var se *request.StatusError + if errors.As(err, &se) && se.HasStatus(http.StatusForbidden, http.StatusNotFound, http.StatusBadGateway) { + return 0, api.ErrNotAvailable + } + + if err != nil { + return 0, err + } + + if res.SocTarget != nil { + return int64(*res.SocTarget), nil } return 0, api.ErrNotAvailable @@ -123,16 +158,16 @@ var _ api.VehicleFinishTimer = (*Provider)(nil) // FinishTime implements the api.VehicleFinishTimer interface func (v *Provider) FinishTime() (time.Time, error) { - res, err := v.batteryG() + res, err := v.batteryStatusG() if err == nil { - timestamp, err := time.Parse(time.RFC3339, res.Data.Attributes.Timestamp) + timestamp, err := time.Parse(time.RFC3339, res.Timestamp) - if res.Data.Attributes.RemainingTime == nil { + if res.RemainingTime == nil { return time.Time{}, api.ErrNotAvailable } - return timestamp.Add(time.Duration(*res.Data.Attributes.RemainingTime) * time.Minute), err + return timestamp.Add(time.Duration(*res.RemainingTime) * time.Minute), err } return time.Time{}, err @@ -145,12 +180,13 @@ func (v *Provider) Climater() (bool, error) { res, err := v.hvacG() // Zoe Ph2, Megane e-tech - if se := new(request.StatusError); errors.As(err, &se) && se.HasStatus(http.StatusForbidden, http.StatusNotFound, http.StatusBadGateway) { + var se *request.StatusError + if errors.As(err, &se) && se.HasStatus(http.StatusForbidden, http.StatusNotFound, http.StatusBadGateway) { return false, api.ErrNotAvailable } if err == nil { - state := strings.ToLower(res.Data.Attributes.HvacStatus) + state := strings.ToLower(res.HvacStatus) if state == "" { return false, api.ErrNotAvailable } @@ -166,8 +202,7 @@ var _ api.Resurrector = (*Provider)(nil) // WakeUp implements the api.Resurrector interface func (v *Provider) WakeUp() error { - _, err := v.wakeup() - return err + return v.wakeup() } var _ api.VehiclePosition = (*Provider)(nil) @@ -176,7 +211,7 @@ var _ api.VehiclePosition = (*Provider)(nil) func (v *Provider) Position() (float64, float64, error) { res, err := v.position() if err == nil { - return res.Data.Attributes.Latitude, res.Data.Attributes.Longitude, nil + return res.Latitude, res.Longitude, nil } return 0, 0, err @@ -187,6 +222,6 @@ var _ api.ChargeController = (*Provider)(nil) // ChargeEnable implements the api.ChargeController interface func (v *Provider) ChargeEnable(enable bool) error { action := map[bool]string{true: kamereon.ActionStart, false: kamereon.ActionStop} - _, err := v.action(action[enable]) + _, err := v.chargeAction(action[enable]) return err }