Renault: add SocLimiter (#25757)

Co-authored-by: andig <andi@evcc.io>
This commit is contained in:
Sam Feyaerts 2025-12-11 21:26:30 +01:00 • committed by GitHub
parent 73ae646023
commit 709fc827e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 306 additions and 145 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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
}