Polestar: add gRPC vehicle implementation (#30119)

This commit is contained in:
andig 2026-05-23 12:37:17 +02:00 • committed by GitHub
parent 8dc0edd8d9
commit 0847c2a046
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1138 additions and 1 deletions

View file

@ -8,5 +8,5 @@ params:
- name: vin
example: LPSVS...
render: |
type: polestar
type: polestar-grpc
{{ include "vehicle-base" . }}

View file

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

147
vehicle/polestar/grpc.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

75
vehicle/polestargrpc.go Normal file
View file

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