diff --git a/templates/definition/vehicle/polestar.yaml b/templates/definition/vehicle/polestar.yaml index 33e4fdba4..502a1a019 100644 --- a/templates/definition/vehicle/polestar.yaml +++ b/templates/definition/vehicle/polestar.yaml @@ -8,5 +8,5 @@ params: - name: vin example: LPSVS... render: | - type: polestar + type: polestar-grpc {{ include "vehicle-base" . }} diff --git a/vehicle/polestar/api.go b/vehicle/polestar/api.go index aa8d1c91d..d460f29cb 100644 --- a/vehicle/polestar/api.go +++ b/vehicle/polestar/api.go @@ -3,6 +3,7 @@ package polestar import ( "context" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/hasura/go-graphql-client" @@ -82,3 +83,29 @@ func (v *API) CarTelemetry(ctx context.Context, vin string) (CarTelemetryData, e return filteredData, err } + +// Odometer returns the odometer reading in km for the given VIN. +// The gRPC battery service does not expose odometer data, so it is queried +// via GraphQL. Only the odometer field is requested, avoiding the removed +// battery chargingStatus field that breaks the full telematics query. +func (v *API) Odometer(ctx context.Context, vin string) (float64, error) { + var res struct { + CarTelematicsV2 struct { + Odometer []OdometerData + } `graphql:"carTelematicsV2(vins: $vins)"` + } + + if err := v.client.Query(ctx, &res, map[string]any{ + "vins": []string{vin}, + }, graphql.OperationName("CarTelematicsV2")); err != nil { + return 0, err + } + + for _, o := range res.CarTelematicsV2.Odometer { + if o.VIN == vin { + return float64(o.OdometerMeters) / 1e3, nil + } + } + + return 0, api.ErrNotAvailable +} diff --git a/vehicle/polestar/grpc.go b/vehicle/polestar/grpc.go new file mode 100644 index 000000000..70de34f8d --- /dev/null +++ b/vehicle/polestar/grpc.go @@ -0,0 +1,147 @@ +package polestar + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "strconv" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/polestar/pb" + "github.com/google/uuid" + "golang.org/x/oauth2" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// Polestar gRPC battery API. +// +// Polestar removed the chargingStatus field from the GraphQL API. The gRPC +// battery service still exposes the charger connection and charging status. +// Protocol reconstructed by the pypolestar project, see +// https://github.com/pypolestar/pypolestar. +const ( + // c3DiscoveryURI resolves the dynamic gRPC host of the battery service + c3DiscoveryURI = "https://cnepmob.volvocars.com" + c3DiscoveryAccept = "application/volvo.cloud.cnepmob.v1+json" + + // defaultGrpcPort is used when discovery does not return a port + defaultGrpcPort = 443 +) + +// GrpcAPI is the Polestar gRPC battery API client +type GrpcAPI struct { + client pb.BatteryServiceClient +} + +// NewGrpcAPI creates a Polestar gRPC battery API client. The token source is +// shared with the GraphQL API so both reuse the same OAuth2 session. +func NewGrpcAPI(log *util.Logger, ts oauth2.TokenSource) (*GrpcAPI, error) { + host, err := discoverC3Host(log) + if err != nil { + return nil, fmt.Errorf("c3 discovery: %w", err) + } + + conn, err := grpc.NewClient(host, + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})), + grpc.WithPerRPCCredentials(tokenCredentials{ts}), + ) + if err != nil { + return nil, err + } + + return &GrpcAPI{ + client: pb.NewBatteryServiceClient(conn), + }, nil +} + +// tokenCredentials adapts an oauth2.TokenSource to grpc.PerRPCCredentials, +// adding the bearer token as gRPC authorization metadata on every call. +type tokenCredentials struct { + oauth2.TokenSource +} + +func (c tokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + token, err := c.Token() + if err != nil { + return nil, err + } + return map[string]string{ + "authorization": token.Type() + " " + token.AccessToken, + }, nil +} + +func (tokenCredentials) RequireTransportSecurity() bool { + return true +} + +// discoverC3Host resolves the C3 gRPC host:port via the cnepmob discovery endpoint +func discoverC3Host(log *util.Logger) (string, error) { + req, err := request.New(http.MethodGet, c3DiscoveryURI, nil, map[string]string{ + "Accept": c3DiscoveryAccept, + }) + if err != nil { + return "", err + } + + var res struct { + C3 struct { + GrpcHost string `json:"grpcHost"` + GrpcPort int `json:"grpcPort"` + } `json:"c3"` + } + if err := request.NewHelper(log).DoJSON(req, &res); err != nil { + return "", err + } + + if res.C3.GrpcHost == "" { + return "", errors.New("missing grpc host") + } + + port := res.C3.GrpcPort + if port == 0 { + port = defaultGrpcPort + } + + return net.JoinHostPort(res.C3.GrpcHost, strconv.Itoa(port)), nil +} + +// Battery returns the latest battery state for the given VIN +func (v *GrpcAPI) Battery(ctx context.Context, vin string) (*pb.Battery, error) { + ctx = metadata.AppendToOutgoingContext(ctx, "vin", vin) + + res, err := v.client.GetLatestBattery(ctx, &pb.GetBatteryRequest{ + Id: uuid.NewString(), + Vin: vin, + }) + if err != nil { + return nil, retryable(err) + } + + battery := res.GetBattery() + if battery == nil { + return nil, api.ErrNotAvailable + } + + return battery, nil +} + +// retryable marks transient gRPC errors as api.ErrMustRetry so util.Cached +// retries them on the next cycle instead of caching the failure and delaying +// recovery via exponential back-off. +func retryable(err error) error { + switch status.Code(err) { + case codes.Unavailable, codes.DeadlineExceeded: + return fmt.Errorf("%w: %w", api.ErrMustRetry, err) + default: + return err + } +} diff --git a/vehicle/polestar/pb/polestar.pb.go b/vehicle/polestar/pb/polestar.pb.go new file mode 100644 index 000000000..fdb530d2f --- /dev/null +++ b/vehicle/polestar/pb/polestar.pb.go @@ -0,0 +1,505 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: polestar.proto + +// Polestar gRPC API (PCCS vehicle battery service). +// +// Polestar removed the chargingStatus field from the GraphQL carTelematicsV2 +// API. The gRPC battery service still exposes the charger connection and +// charging status. Protocol reconstructed by the pypolestar project from the +// Polestar Android app, see https://github.com/pypolestar/pypolestar. +// +// The proto package name is significant: it forms the gRPC method path +// (/services.vehiclestates.battery.BatteryService/GetLatestBattery) expected +// by the server. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative polestar.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ChargerConnectionStatus int32 + +const ( + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_UNSPECIFIED ChargerConnectionStatus = 0 + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED ChargerConnectionStatus = 1 + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_DISCONNECTED ChargerConnectionStatus = 2 + ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_FAULT ChargerConnectionStatus = 3 +) + +// Enum value maps for ChargerConnectionStatus. +var ( + ChargerConnectionStatus_name = map[int32]string{ + 0: "CHARGER_CONNECTION_STATUS_UNSPECIFIED", + 1: "CHARGER_CONNECTION_STATUS_CONNECTED", + 2: "CHARGER_CONNECTION_STATUS_DISCONNECTED", + 3: "CHARGER_CONNECTION_STATUS_FAULT", + } + ChargerConnectionStatus_value = map[string]int32{ + "CHARGER_CONNECTION_STATUS_UNSPECIFIED": 0, + "CHARGER_CONNECTION_STATUS_CONNECTED": 1, + "CHARGER_CONNECTION_STATUS_DISCONNECTED": 2, + "CHARGER_CONNECTION_STATUS_FAULT": 3, + } +) + +func (x ChargerConnectionStatus) Enum() *ChargerConnectionStatus { + p := new(ChargerConnectionStatus) + *p = x + return p +} + +func (x ChargerConnectionStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ChargerConnectionStatus) Descriptor() protoreflect.EnumDescriptor { + return file_polestar_proto_enumTypes[0].Descriptor() +} + +func (ChargerConnectionStatus) Type() protoreflect.EnumType { + return &file_polestar_proto_enumTypes[0] +} + +func (x ChargerConnectionStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ChargerConnectionStatus.Descriptor instead. +func (ChargerConnectionStatus) EnumDescriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{0} +} + +type ChargingStatus int32 + +const ( + ChargingStatus_CHARGING_STATUS_UNSPECIFIED ChargingStatus = 0 + ChargingStatus_CHARGING_STATUS_CHARGING ChargingStatus = 1 + ChargingStatus_CHARGING_STATUS_IDLE ChargingStatus = 2 + ChargingStatus_CHARGING_STATUS_DONE ChargingStatus = 3 + ChargingStatus_CHARGING_STATUS_FAULT ChargingStatus = 4 + ChargingStatus_CHARGING_STATUS_SCHEDULED ChargingStatus = 5 + ChargingStatus_CHARGING_STATUS_DISCHARGING ChargingStatus = 6 + ChargingStatus_CHARGING_STATUS_ERROR ChargingStatus = 7 + ChargingStatus_CHARGING_STATUS_SMART_CHARGING ChargingStatus = 8 +) + +// Enum value maps for ChargingStatus. +var ( + ChargingStatus_name = map[int32]string{ + 0: "CHARGING_STATUS_UNSPECIFIED", + 1: "CHARGING_STATUS_CHARGING", + 2: "CHARGING_STATUS_IDLE", + 3: "CHARGING_STATUS_DONE", + 4: "CHARGING_STATUS_FAULT", + 5: "CHARGING_STATUS_SCHEDULED", + 6: "CHARGING_STATUS_DISCHARGING", + 7: "CHARGING_STATUS_ERROR", + 8: "CHARGING_STATUS_SMART_CHARGING", + } + ChargingStatus_value = map[string]int32{ + "CHARGING_STATUS_UNSPECIFIED": 0, + "CHARGING_STATUS_CHARGING": 1, + "CHARGING_STATUS_IDLE": 2, + "CHARGING_STATUS_DONE": 3, + "CHARGING_STATUS_FAULT": 4, + "CHARGING_STATUS_SCHEDULED": 5, + "CHARGING_STATUS_DISCHARGING": 6, + "CHARGING_STATUS_ERROR": 7, + "CHARGING_STATUS_SMART_CHARGING": 8, + } +) + +func (x ChargingStatus) Enum() *ChargingStatus { + p := new(ChargingStatus) + *p = x + return p +} + +func (x ChargingStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ChargingStatus) Descriptor() protoreflect.EnumDescriptor { + return file_polestar_proto_enumTypes[1].Descriptor() +} + +func (ChargingStatus) Type() protoreflect.EnumType { + return &file_polestar_proto_enumTypes[1] +} + +func (x ChargingStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ChargingStatus.Descriptor instead. +func (ChargingStatus) EnumDescriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{1} +} + +type Timestamp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Seconds int64 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"` + Nanos int32 `protobuf:"varint,2,opt,name=nanos,proto3" json:"nanos,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Timestamp) Reset() { + *x = Timestamp{} + mi := &file_polestar_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Timestamp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Timestamp) ProtoMessage() {} + +func (x *Timestamp) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Timestamp.ProtoReflect.Descriptor instead. +func (*Timestamp) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{0} +} + +func (x *Timestamp) GetSeconds() int64 { + if x != nil { + return x.Seconds + } + return 0 +} + +func (x *Timestamp) GetNanos() int32 { + if x != nil { + return x.Nanos + } + return 0 +} + +type Battery struct { + state protoimpl.MessageState `protogen:"open.v1"` + Timestamp *Timestamp `protobuf:"bytes,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + BatteryChargeLevelPercentage float64 `protobuf:"fixed64,2,opt,name=battery_charge_level_percentage,json=batteryChargeLevelPercentage,proto3" json:"battery_charge_level_percentage,omitempty"` + EstimatedDistanceToEmptyKm int32 `protobuf:"varint,4,opt,name=estimated_distance_to_empty_km,json=estimatedDistanceToEmptyKm,proto3" json:"estimated_distance_to_empty_km,omitempty"` + EstimatedChargingTimeToFullMinutes int32 `protobuf:"varint,5,opt,name=estimated_charging_time_to_full_minutes,json=estimatedChargingTimeToFullMinutes,proto3" json:"estimated_charging_time_to_full_minutes,omitempty"` + ChargerConnectionStatus ChargerConnectionStatus `protobuf:"varint,6,opt,name=charger_connection_status,json=chargerConnectionStatus,proto3,enum=services.vehiclestates.battery.ChargerConnectionStatus" json:"charger_connection_status,omitempty"` + ChargingStatus ChargingStatus `protobuf:"varint,7,opt,name=charging_status,json=chargingStatus,proto3,enum=services.vehiclestates.battery.ChargingStatus" json:"charging_status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Battery) Reset() { + *x = Battery{} + mi := &file_polestar_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Battery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Battery) ProtoMessage() {} + +func (x *Battery) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Battery.ProtoReflect.Descriptor instead. +func (*Battery) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{1} +} + +func (x *Battery) GetTimestamp() *Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +func (x *Battery) GetBatteryChargeLevelPercentage() float64 { + if x != nil { + return x.BatteryChargeLevelPercentage + } + return 0 +} + +func (x *Battery) GetEstimatedDistanceToEmptyKm() int32 { + if x != nil { + return x.EstimatedDistanceToEmptyKm + } + return 0 +} + +func (x *Battery) GetEstimatedChargingTimeToFullMinutes() int32 { + if x != nil { + return x.EstimatedChargingTimeToFullMinutes + } + return 0 +} + +func (x *Battery) GetChargerConnectionStatus() ChargerConnectionStatus { + if x != nil { + return x.ChargerConnectionStatus + } + return ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_UNSPECIFIED +} + +func (x *Battery) GetChargingStatus() ChargingStatus { + if x != nil { + return x.ChargingStatus + } + return ChargingStatus_CHARGING_STATUS_UNSPECIFIED +} + +type GetBatteryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Vin string `protobuf:"bytes,2,opt,name=vin,proto3" json:"vin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBatteryRequest) Reset() { + *x = GetBatteryRequest{} + mi := &file_polestar_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBatteryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBatteryRequest) ProtoMessage() {} + +func (x *GetBatteryRequest) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBatteryRequest.ProtoReflect.Descriptor instead. +func (*GetBatteryRequest) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{2} +} + +func (x *GetBatteryRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetBatteryRequest) GetVin() string { + if x != nil { + return x.Vin + } + return "" +} + +type GetBatteryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Vin string `protobuf:"bytes,2,opt,name=vin,proto3" json:"vin,omitempty"` + Battery *Battery `protobuf:"bytes,3,opt,name=battery,proto3" json:"battery,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBatteryResponse) Reset() { + *x = GetBatteryResponse{} + mi := &file_polestar_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBatteryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBatteryResponse) ProtoMessage() {} + +func (x *GetBatteryResponse) ProtoReflect() protoreflect.Message { + mi := &file_polestar_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBatteryResponse.ProtoReflect.Descriptor instead. +func (*GetBatteryResponse) Descriptor() ([]byte, []int) { + return file_polestar_proto_rawDescGZIP(), []int{3} +} + +func (x *GetBatteryResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetBatteryResponse) GetVin() string { + if x != nil { + return x.Vin + } + return "" +} + +func (x *GetBatteryResponse) GetBattery() *Battery { + if x != nil { + return x.Battery + } + return nil +} + +var File_polestar_proto protoreflect.FileDescriptor + +const file_polestar_proto_rawDesc = "" + + "\n" + + "\x0epolestar.proto\x12\x1eservices.vehiclestates.battery\";\n" + + "\tTimestamp\x12\x18\n" + + "\aseconds\x18\x01 \x01(\x03R\aseconds\x12\x14\n" + + "\x05nanos\x18\x02 \x01(\x05R\x05nanos\"\x80\x04\n" + + "\aBattery\x12G\n" + + "\ttimestamp\x18\x01 \x01(\v2).services.vehiclestates.battery.TimestampR\ttimestamp\x12E\n" + + "\x1fbattery_charge_level_percentage\x18\x02 \x01(\x01R\x1cbatteryChargeLevelPercentage\x12B\n" + + "\x1eestimated_distance_to_empty_km\x18\x04 \x01(\x05R\x1aestimatedDistanceToEmptyKm\x12S\n" + + "'estimated_charging_time_to_full_minutes\x18\x05 \x01(\x05R\"estimatedChargingTimeToFullMinutes\x12s\n" + + "\x19charger_connection_status\x18\x06 \x01(\x0e27.services.vehiclestates.battery.ChargerConnectionStatusR\x17chargerConnectionStatus\x12W\n" + + "\x0fcharging_status\x18\a \x01(\x0e2..services.vehiclestates.battery.ChargingStatusR\x0echargingStatus\"5\n" + + "\x11GetBatteryRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03vin\x18\x02 \x01(\tR\x03vin\"y\n" + + "\x12GetBatteryResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03vin\x18\x02 \x01(\tR\x03vin\x12A\n" + + "\abattery\x18\x03 \x01(\v2'.services.vehiclestates.battery.BatteryR\abattery*\xbe\x01\n" + + "\x17ChargerConnectionStatus\x12)\n" + + "%CHARGER_CONNECTION_STATUS_UNSPECIFIED\x10\x00\x12'\n" + + "#CHARGER_CONNECTION_STATUS_CONNECTED\x10\x01\x12*\n" + + "&CHARGER_CONNECTION_STATUS_DISCONNECTED\x10\x02\x12#\n" + + "\x1fCHARGER_CONNECTION_STATUS_FAULT\x10\x03*\x9d\x02\n" + + "\x0eChargingStatus\x12\x1f\n" + + "\x1bCHARGING_STATUS_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18CHARGING_STATUS_CHARGING\x10\x01\x12\x18\n" + + "\x14CHARGING_STATUS_IDLE\x10\x02\x12\x18\n" + + "\x14CHARGING_STATUS_DONE\x10\x03\x12\x19\n" + + "\x15CHARGING_STATUS_FAULT\x10\x04\x12\x1d\n" + + "\x19CHARGING_STATUS_SCHEDULED\x10\x05\x12\x1f\n" + + "\x1bCHARGING_STATUS_DISCHARGING\x10\x06\x12\x19\n" + + "\x15CHARGING_STATUS_ERROR\x10\a\x12\"\n" + + "\x1eCHARGING_STATUS_SMART_CHARGING\x10\b2\x8b\x01\n" + + "\x0eBatteryService\x12y\n" + + "\x10GetLatestBattery\x121.services.vehiclestates.battery.GetBatteryRequest\x1a2.services.vehiclestates.battery.GetBatteryResponseB-Z+github.com/evcc-io/evcc/vehicle/polestar/pbb\x06proto3" + +var ( + file_polestar_proto_rawDescOnce sync.Once + file_polestar_proto_rawDescData []byte +) + +func file_polestar_proto_rawDescGZIP() []byte { + file_polestar_proto_rawDescOnce.Do(func() { + file_polestar_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_polestar_proto_rawDesc), len(file_polestar_proto_rawDesc))) + }) + return file_polestar_proto_rawDescData +} + +var file_polestar_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_polestar_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_polestar_proto_goTypes = []any{ + (ChargerConnectionStatus)(0), // 0: services.vehiclestates.battery.ChargerConnectionStatus + (ChargingStatus)(0), // 1: services.vehiclestates.battery.ChargingStatus + (*Timestamp)(nil), // 2: services.vehiclestates.battery.Timestamp + (*Battery)(nil), // 3: services.vehiclestates.battery.Battery + (*GetBatteryRequest)(nil), // 4: services.vehiclestates.battery.GetBatteryRequest + (*GetBatteryResponse)(nil), // 5: services.vehiclestates.battery.GetBatteryResponse +} +var file_polestar_proto_depIdxs = []int32{ + 2, // 0: services.vehiclestates.battery.Battery.timestamp:type_name -> services.vehiclestates.battery.Timestamp + 0, // 1: services.vehiclestates.battery.Battery.charger_connection_status:type_name -> services.vehiclestates.battery.ChargerConnectionStatus + 1, // 2: services.vehiclestates.battery.Battery.charging_status:type_name -> services.vehiclestates.battery.ChargingStatus + 3, // 3: services.vehiclestates.battery.GetBatteryResponse.battery:type_name -> services.vehiclestates.battery.Battery + 4, // 4: services.vehiclestates.battery.BatteryService.GetLatestBattery:input_type -> services.vehiclestates.battery.GetBatteryRequest + 5, // 5: services.vehiclestates.battery.BatteryService.GetLatestBattery:output_type -> services.vehiclestates.battery.GetBatteryResponse + 5, // [5:6] is the sub-list for method output_type + 4, // [4:5] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_polestar_proto_init() } +func file_polestar_proto_init() { + if File_polestar_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_polestar_proto_rawDesc), len(file_polestar_proto_rawDesc)), + NumEnums: 2, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_polestar_proto_goTypes, + DependencyIndexes: file_polestar_proto_depIdxs, + EnumInfos: file_polestar_proto_enumTypes, + MessageInfos: file_polestar_proto_msgTypes, + }.Build() + File_polestar_proto = out.File + file_polestar_proto_goTypes = nil + file_polestar_proto_depIdxs = nil +} diff --git a/vehicle/polestar/pb/polestar.proto b/vehicle/polestar/pb/polestar.proto new file mode 100644 index 000000000..fd4ea5a29 --- /dev/null +++ b/vehicle/polestar/pb/polestar.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +// Polestar gRPC API (PCCS vehicle battery service). +// +// Polestar removed the chargingStatus field from the GraphQL carTelematicsV2 +// API. The gRPC battery service still exposes the charger connection and +// charging status. Protocol reconstructed by the pypolestar project from the +// Polestar Android app, see https://github.com/pypolestar/pypolestar. +// +// The proto package name is significant: it forms the gRPC method path +// (/services.vehiclestates.battery.BatteryService/GetLatestBattery) expected +// by the server. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative polestar.proto + +package services.vehiclestates.battery; + +option go_package = "github.com/evcc-io/evcc/vehicle/polestar/pb"; + +enum ChargerConnectionStatus { + CHARGER_CONNECTION_STATUS_UNSPECIFIED = 0; + CHARGER_CONNECTION_STATUS_CONNECTED = 1; + CHARGER_CONNECTION_STATUS_DISCONNECTED = 2; + CHARGER_CONNECTION_STATUS_FAULT = 3; +} + +enum ChargingStatus { + CHARGING_STATUS_UNSPECIFIED = 0; + CHARGING_STATUS_CHARGING = 1; + CHARGING_STATUS_IDLE = 2; + CHARGING_STATUS_DONE = 3; + CHARGING_STATUS_FAULT = 4; + CHARGING_STATUS_SCHEDULED = 5; + CHARGING_STATUS_DISCHARGING = 6; + CHARGING_STATUS_ERROR = 7; + CHARGING_STATUS_SMART_CHARGING = 8; +} + +message Timestamp { + int64 seconds = 1; + int32 nanos = 2; +} + +message Battery { + Timestamp timestamp = 1; + double battery_charge_level_percentage = 2; + int32 estimated_distance_to_empty_km = 4; + int32 estimated_charging_time_to_full_minutes = 5; + ChargerConnectionStatus charger_connection_status = 6; + ChargingStatus charging_status = 7; +} + +message GetBatteryRequest { + string id = 1; + string vin = 2; +} + +message GetBatteryResponse { + string id = 1; + string vin = 2; + Battery battery = 3; +} + +service BatteryService { + rpc GetLatestBattery(GetBatteryRequest) returns (GetBatteryResponse); +} diff --git a/vehicle/polestar/pb/polestar_grpc.pb.go b/vehicle/polestar/pb/polestar_grpc.pb.go new file mode 100644 index 000000000..650ba6a6a --- /dev/null +++ b/vehicle/polestar/pb/polestar_grpc.pb.go @@ -0,0 +1,136 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc v7.34.1 +// source: polestar.proto + +// Polestar gRPC API (PCCS vehicle battery service). +// +// Polestar removed the chargingStatus field from the GraphQL carTelematicsV2 +// API. The gRPC battery service still exposes the charger connection and +// charging status. Protocol reconstructed by the pypolestar project from the +// Polestar Android app, see https://github.com/pypolestar/pypolestar. +// +// The proto package name is significant: it forms the gRPC method path +// (/services.vehiclestates.battery.BatteryService/GetLatestBattery) expected +// by the server. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative polestar.proto + +package pb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + BatteryService_GetLatestBattery_FullMethodName = "/services.vehiclestates.battery.BatteryService/GetLatestBattery" +) + +// BatteryServiceClient is the client API for BatteryService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type BatteryServiceClient interface { + GetLatestBattery(ctx context.Context, in *GetBatteryRequest, opts ...grpc.CallOption) (*GetBatteryResponse, error) +} + +type batteryServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewBatteryServiceClient(cc grpc.ClientConnInterface) BatteryServiceClient { + return &batteryServiceClient{cc} +} + +func (c *batteryServiceClient) GetLatestBattery(ctx context.Context, in *GetBatteryRequest, opts ...grpc.CallOption) (*GetBatteryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetBatteryResponse) + err := c.cc.Invoke(ctx, BatteryService_GetLatestBattery_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// BatteryServiceServer is the server API for BatteryService service. +// All implementations must embed UnimplementedBatteryServiceServer +// for forward compatibility. +type BatteryServiceServer interface { + GetLatestBattery(context.Context, *GetBatteryRequest) (*GetBatteryResponse, error) + mustEmbedUnimplementedBatteryServiceServer() +} + +// UnimplementedBatteryServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedBatteryServiceServer struct{} + +func (UnimplementedBatteryServiceServer) GetLatestBattery(context.Context, *GetBatteryRequest) (*GetBatteryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetLatestBattery not implemented") +} +func (UnimplementedBatteryServiceServer) mustEmbedUnimplementedBatteryServiceServer() {} +func (UnimplementedBatteryServiceServer) testEmbeddedByValue() {} + +// UnsafeBatteryServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to BatteryServiceServer will +// result in compilation errors. +type UnsafeBatteryServiceServer interface { + mustEmbedUnimplementedBatteryServiceServer() +} + +func RegisterBatteryServiceServer(s grpc.ServiceRegistrar, srv BatteryServiceServer) { + // If the following call panics, it indicates UnimplementedBatteryServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&BatteryService_ServiceDesc, srv) +} + +func _BatteryService_GetLatestBattery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetBatteryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BatteryServiceServer).GetLatestBattery(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: BatteryService_GetLatestBattery_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BatteryServiceServer).GetLatestBattery(ctx, req.(*GetBatteryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// BatteryService_ServiceDesc is the grpc.ServiceDesc for BatteryService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var BatteryService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "services.vehiclestates.battery.BatteryService", + HandlerType: (*BatteryServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetLatestBattery", + Handler: _BatteryService_GetLatestBattery_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "polestar.proto", +} diff --git a/vehicle/polestar/provider_grpc.go b/vehicle/polestar/provider_grpc.go new file mode 100644 index 000000000..fdce16447 --- /dev/null +++ b/vehicle/polestar/provider_grpc.go @@ -0,0 +1,106 @@ +package polestar + +import ( + "context" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/polestar/pb" +) + +// GrpcProvider implements the vehicle API using the Polestar gRPC battery +// service for charge state and the GraphQL API for odometer. +type GrpcProvider struct { + batteryG func() (*pb.Battery, error) + odometerG func() (float64, error) +} + +// NewGrpcProvider creates a Polestar gRPC vehicle data provider +func NewGrpcProvider(grpc *GrpcAPI, api *API, vin string, timeout, cache time.Duration) *GrpcProvider { + return &GrpcProvider{ + batteryG: util.Cached(func() (*pb.Battery, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return grpc.Battery(ctx, vin) + }, cache), + odometerG: util.Cached(func() (float64, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return api.Odometer(ctx, vin) + }, cache), + } +} + +var _ api.Battery = (*GrpcProvider)(nil) + +// Soc implements the api.Battery interface +func (v *GrpcProvider) Soc() (float64, error) { + res, err := v.batteryG() + if err != nil { + return 0, err + } + return res.GetBatteryChargeLevelPercentage(), nil +} + +var _ api.ChargeState = (*GrpcProvider)(nil) + +// Status implements the api.ChargeState interface +func (v *GrpcProvider) Status() (api.ChargeStatus, error) { + res, err := v.batteryG() + if err != nil { + return api.StatusNone, err + } + + switch res.GetChargingStatus() { + case pb.ChargingStatus_CHARGING_STATUS_CHARGING, pb.ChargingStatus_CHARGING_STATUS_SMART_CHARGING: + return api.StatusC, nil + } + + if res.GetChargerConnectionStatus() == pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED { + return api.StatusB, nil + } + + return api.StatusA, nil +} + +var _ api.VehicleRange = (*GrpcProvider)(nil) + +// Range implements the api.VehicleRange interface +func (v *GrpcProvider) Range() (int64, error) { + res, err := v.batteryG() + if err != nil { + return 0, err + } + return int64(res.GetEstimatedDistanceToEmptyKm()), nil +} + +var _ api.VehicleFinishTimer = (*GrpcProvider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *GrpcProvider) FinishTime() (time.Time, error) { + res, err := v.batteryG() + if err != nil { + return time.Time{}, err + } + + minutes := res.GetEstimatedChargingTimeToFullMinutes() + if minutes <= 0 { + return time.Time{}, api.ErrNotAvailable + } + + // anchor the relative remaining time to the API's capture timestamp + base := time.Now() + if ts := res.GetTimestamp(); ts.GetSeconds() > 0 { + base = time.Unix(ts.GetSeconds(), int64(ts.GetNanos())) + } + + return base.Add(time.Duration(minutes) * time.Minute), nil +} + +var _ api.VehicleOdometer = (*GrpcProvider)(nil) + +// Odometer implements the api.VehicleOdometer interface +func (v *GrpcProvider) Odometer() (float64, error) { + return v.odometerG() +} diff --git a/vehicle/polestar/provider_grpc_test.go b/vehicle/polestar/provider_grpc_test.go new file mode 100644 index 000000000..f29320933 --- /dev/null +++ b/vehicle/polestar/provider_grpc_test.go @@ -0,0 +1,73 @@ +package polestar + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/vehicle/polestar/pb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGrpcStatus(t *testing.T) { + tc := []struct { + name string + connection pb.ChargerConnectionStatus + charging pb.ChargingStatus + expected api.ChargeStatus + }{ + {"disconnected", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_DISCONNECTED, pb.ChargingStatus_CHARGING_STATUS_IDLE, api.StatusA}, + {"unspecified", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_UNSPECIFIED, pb.ChargingStatus_CHARGING_STATUS_UNSPECIFIED, api.StatusA}, + {"connected idle", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_IDLE, api.StatusB}, + {"connected done", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_DONE, api.StatusB}, + {"charging", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_CHARGING, api.StatusC}, + {"smart charging", pb.ChargerConnectionStatus_CHARGER_CONNECTION_STATUS_CONNECTED, pb.ChargingStatus_CHARGING_STATUS_SMART_CHARGING, api.StatusC}, + } + + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + v := &GrpcProvider{ + batteryG: func() (*pb.Battery, error) { + return &pb.Battery{ + ChargerConnectionStatus: tc.connection, + ChargingStatus: tc.charging, + }, nil + }, + } + + status, err := v.Status() + require.NoError(t, err) + assert.Equal(t, tc.expected, status) + }) + } +} + +func TestGrpcFinishTime(t *testing.T) { + captured := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + v := &GrpcProvider{ + batteryG: func() (*pb.Battery, error) { + return &pb.Battery{ + EstimatedChargingTimeToFullMinutes: 30, + Timestamp: &pb.Timestamp{Seconds: captured.Unix()}, + }, nil + }, + } + + // finish time is anchored to the API capture timestamp, not time.Now() + ft, err := v.FinishTime() + require.NoError(t, err) + assert.True(t, captured.Add(30*time.Minute).Equal(ft)) +} + +func TestGrpcFinishTimeNotCharging(t *testing.T) { + v := &GrpcProvider{ + batteryG: func() (*pb.Battery, error) { + return &pb.Battery{EstimatedChargingTimeToFullMinutes: 0}, nil + }, + } + + _, err := v.FinishTime() + assert.ErrorIs(t, err, api.ErrNotAvailable) +} diff --git a/vehicle/polestargrpc.go b/vehicle/polestargrpc.go new file mode 100644 index 000000000..db2320a60 --- /dev/null +++ b/vehicle/polestargrpc.go @@ -0,0 +1,75 @@ +package vehicle + +import ( + "context" + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/polestar" +) + +// PolestarGRPC is an api.Vehicle implementation for Polestar cars using the +// gRPC battery API. It restores the charging status that Polestar removed from +// the GraphQL API, see https://github.com/evcc-io/evcc/issues/30071. +type PolestarGRPC struct { + *embed + *polestar.GrpcProvider +} + +func init() { + registry.Add("polestar-grpc", NewPolestarGRPCFromConfig) +} + +// NewPolestarGRPCFromConfig creates a new vehicle +func NewPolestarGRPCFromConfig(other map[string]any) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + User, Password string + VIN string + Cache time.Duration + Timeout time.Duration + }{ + Cache: interval, + Timeout: request.Timeout, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("polestar").Redact(cc.User, cc.Password, cc.VIN) + + v := &PolestarGRPC{ + embed: &cc.embed, + } + + identity, err := polestar.NewIdentity(log, cc.User, cc.Password) + if err != nil { + return v, fmt.Errorf("login failed: %w", err) + } + + api := polestar.NewAPI(log, identity) + + vehicle, err := ensureVehicleEx(cc.VIN, func() ([]polestar.ConsumerCar, error) { + ctx, cancel := context.WithTimeout(context.Background(), cc.Timeout) + defer cancel() + return api.Vehicles(ctx) + }, func(v polestar.ConsumerCar) (string, error) { + return v.VIN, nil + }) + if err != nil { + return v, err + } + + grpcAPI, err := polestar.NewGrpcAPI(log, identity) + if err != nil { + return v, fmt.Errorf("grpc: %w", err) + } + + v.GrpcProvider = polestar.NewGrpcProvider(grpcAPI, api, vehicle.VIN, cc.Timeout, cc.Cache) + + return v, nil +}