Polestar: add gRPC vehicle implementation (#30119)
This commit is contained in:
parent
8dc0edd8d9
commit
0847c2a046
9 changed files with 1138 additions and 1 deletions
|
|
@ -8,5 +8,5 @@ params:
|
|||
- name: vin
|
||||
example: LPSVS...
|
||||
render: |
|
||||
type: polestar
|
||||
type: polestar-grpc
|
||||
{{ include "vehicle-base" . }}
|
||||
|
|
|
|||
|
|
@ -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
147
vehicle/polestar/grpc.go
Normal 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
|
||||
}
|
||||
}
|
||||
505
vehicle/polestar/pb/polestar.pb.go
Normal file
505
vehicle/polestar/pb/polestar.pb.go
Normal 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
|
||||
}
|
||||
68
vehicle/polestar/pb/polestar.proto
Normal file
68
vehicle/polestar/pb/polestar.proto
Normal 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);
|
||||
}
|
||||
136
vehicle/polestar/pb/polestar_grpc.pb.go
Normal file
136
vehicle/polestar/pb/polestar_grpc.pb.go
Normal 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",
|
||||
}
|
||||
106
vehicle/polestar/provider_grpc.go
Normal file
106
vehicle/polestar/provider_grpc.go
Normal 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()
|
||||
}
|
||||
73
vehicle/polestar/provider_grpc_test.go
Normal file
73
vehicle/polestar/provider_grpc_test.go
Normal 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
75
vehicle/polestargrpc.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue