Repository: evcc-io/evcc
Pull request #13741 (2024-05-04 08:59:40)

Mqtt: publish pointer values
Fix https://github.com/evcc-io/evcc/issues/13666


Patch:
diff --git a/charger/sungrow.go b/charger/sungrow.go
index 61e43dd1..d7fe59cf 100644
--- a/charger/sungrow.go
+++ b/charger/sungrow.go
@@ -32,27 +32,28 @@ import (
 type Sungrow struct {
 	log  *util.Logger
 	conn *modbus.Connection
+	curr uint16
 }
 
 const (
 	// input (read only)
-	sgRegPhase         = 21224 // uint16 [1: Single-phase,	3: Three-phase]
-	sgRegWorkMode      = 21262 // uint16 [0: Network, 2: Plug&Play, 6: EMS]
-	sgRegRemCtrlStatus = 21267 // uint16
-	sgRegPhasesState   = 21269 // uint16
-	sgRegTotalEnergy   = 21299 // uint32s 1Wh
-	sgRegActivePower   = 21307 // uint32s 1W
-	sgRegChargedEnergy = 21309 // uint32s 1Wh
-	sgRegStartMode     = 21313 // uint16 [1: Started by EMS, 2: Started by swiping card]
-	sgRegPowerRequest  = 21314 // uint16 [0: Enable, 1: Close]
-	sgRegPowerFlag     = 21315 // uint16 [0: Charging or power regulation is not allowed; 1: Charging or power regulation is allowed]
-	sgRegState         = 21316 // uint16
+	sgRegPhase             = 21224 // uint16 [1: Single-phase,	3: Three-phase]
+	sgRegWorkMode          = 21262 // uint16 [0: Network, 2: Plug&Play, 6: EMS]
+	sgRegRemCtrlStatus     = 21267 // uint16
+	sgRegPhaseSwitchStatus = 21269 // uint16
+	sgRegTotalEnergy       = 21299 // uint32s 1Wh
+	sgRegActivePower       = 21307 // uint32s 1W
+	sgRegChargedEnergy     = 21309 // uint32s 1Wh
+	sgRegStartMode         = 21313 // uint16 [1: Started by EMS, 2: Started by swiping card]
+	sgRegPowerRequest      = 21314 // uint16 [0: Enable, 1: Close]
+	sgRegPowerFlag         = 21315 // uint16 [0: Charging or power regulation is not allowed; 1: Charging or power regulation is allowed]
+	sgRegState             = 21316 // uint16
 
 	// holding
 	sgRegSetOutI       = 21202 // uint16 0.01A
 	sgRegPhaseSwitch   = 21203 // uint16
 	sgRegUnavailable   = 21210 // uint16
-	sgRegRemoteControl = 21211 // uint16
+	sgRegRemoteControl = 21211 // uint16 [0: Start, 1: Stop]
 )
 
 var (
@@ -94,6 +95,7 @@ func NewSungrow(uri, device, comset string, baudrate int, proto modbus.Protocol,
 	wb := &Sungrow{
 		log:  log,
 		conn: conn,
+		curr: 60,
 	}
 
 	return wb, err
@@ -122,20 +124,20 @@ func (wb *Sungrow) Status() (api.ChargeStatus, error) {
 	}
 
 	switch s := binary.BigEndian.Uint16(b); s {
-	case 1: // "Idle"
+	case 1: // Idle
 		return api.StatusA, nil
 	case
-		2, // "Standby"
-		4, // "SuspendedEVSE"
-		5, // "SuspendedEV"
-		6: // "Completed"
+		2, // Standby
+		4, // SuspendedEVSE
+		5, // SuspendedEV
+		6: // Completed
 		return api.StatusB, nil
-	case 3: // "Charging"
+	case 3: // Charging
 		return api.StatusC, nil
 	case
-		7, // "Reserved"
-		8, // "Disabled"
-		9: // "Faulted"
+		7, // Reserved
+		8, // Disabled
+		9: // Faulted
 		return api.StatusF, nil
 	default:
 		return api.StatusNone, fmt.Errorf("invalid status: %d", s)
@@ -144,12 +146,12 @@ func (wb *Sungrow) Status() (api.ChargeStatus, error) {
 
 // Enabled implements the api.Charger interface
 func (wb *Sungrow) Enabled() (bool, error) {
-	b, err := wb.conn.ReadInputRegisters(sgRegRemCtrlStatus, 1)
+	b, err := wb.conn.ReadHoldingRegisters(sgRegSetOutI, 1)
 	if err != nil {
 		return false, err
 	}
 
-	return binary.BigEndian.Uint16(b) == 1, nil
+	return binary.BigEndian.Uint16(b) != 0, nil
 }
 
 // Enable implements the api.Charger interface
@@ -161,6 +163,10 @@ func (wb *Sungrow) Enable(enable bool) error {
 
 	_, err := wb.conn.WriteSingleRegister(sgRegRemoteControl, u)
 
+	if err == nil && enable {
+		_, err = wb.conn.WriteSingleRegister(sgRegSetOutI, wb.curr)
+	}
+
 	return err
 }
 
@@ -177,7 +183,12 @@ func (wb *Sungrow) MaxCurrentMillis(current float64) error {
 		return fmt.Errorf("invalid current %.1f", current)
 	}
 
-	_, err := wb.conn.WriteSingleRegister(sgRegSetOutI, uint16(current*10))
+	curr := uint16(10 * current)
+
+	_, err := wb.conn.WriteSingleRegister(sgRegSetOutI, curr)
+	if err == nil {
+		wb.curr = curr
+	}
 
 	return err
 }
@@ -242,11 +253,38 @@ func (wb *Sungrow) Phases1p3p(phases int) error {
 		u = 1
 	}
 
-	_, err := wb.conn.WriteSingleRegister(sgRegPhaseSwitch, u)
+	enabled, err := wb.Enabled()
+	if err == nil && enabled {
+		if err = wb.Enable(false); err != nil {
+			return err
+		}
+	}
+
+	_, err = wb.conn.WriteSingleRegister(sgRegPhaseSwitch, u)
+
+	if err == nil && enabled {
+		err = wb.Enable(true)
+	}
 
 	return err
 }
 
+var _ api.PhaseGetter = (*Sungrow)(nil)
+
+// GetPhases implements the api.PhaseGetter interface
+func (wb *Sungrow) GetPhases() (int, error) {
+	b, err := wb.conn.ReadInputRegisters(sgRegPhaseSwitchStatus, 1)
+	if err != nil {
+		return 0, err
+	}
+
+	if binary.BigEndian.Uint16(b) == 0 {
+		return 3, nil
+	}
+
+	return 1, nil
+}
+
 var _ api.Diagnosis = (*Sungrow)(nil)
 
 // Diagnose implements the api.Diagnosis interface
@@ -269,7 +307,7 @@ func (wb *Sungrow) Diagnose() {
 	if b, err := wb.conn.ReadInputRegisters(sgRegPhase, 1); err == nil {
 		fmt.Printf("\tPhase:\t%d\n", binary.BigEndian.Uint16(b))
 	}
-	if b, err := wb.conn.ReadInputRegisters(sgRegPhasesState, 1); err == nil {
+	if b, err := wb.conn.ReadInputRegisters(sgRegPhaseSwitchStatus, 1); err == nil {
 		fmt.Printf("\tPhasesState:\t%d\n", binary.BigEndian.Uint16(b))
 	}
 	if b, err := wb.conn.ReadInputRegisters(sgRegStartMode, 1); err == nil {
diff --git a/server/mqtt.go b/server/mqtt.go
index 97561dba..f96c31f0 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -107,9 +107,12 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload interface{})
 		}
 
 	case reflect.Pointer:
-		if reflect.ValueOf(payload).IsNil() {
-			payload = nil
+		if !reflect.ValueOf(payload).IsNil() {
+			m.publishComplex(topic, retained, reflect.Indirect(reflect.ValueOf(payload)).Interface())
+			return
 		}
+
+		payload = nil
 		fallthrough
 
 	default:
diff --git a/templates/definition/charger/sungrow.yaml b/templates/definition/charger/sungrow.yaml
index caced142..dbe0fa45 100644
--- a/templates/definition/charger/sungrow.yaml
+++ b/templates/definition/charger/sungrow.yaml
@@ -3,11 +3,8 @@ products:
   - brand: Sungrow
     description:
       generic: AC011E-01
-capabilities: ["mA"]
+capabilities: ["mA", "1p3p"]
 requirements:
-  description:
-    de: Die Wallbox muss auf EMS-Arbeitsmodus und auf EMS-Start eingestellt werden.
-    en: Charger needs to be set to EMS working mode and start by EMS.
   evcc: ["sponsorship"]
 params:
   - name: modbus
diff --git a/vehicle/bluelink.go b/vehicle/bluelink.go
index 2883e4c9..a85d9998 100644
--- a/vehicle/bluelink.go
+++ b/vehicle/bluelink.go
@@ -94,7 +94,7 @@ func newBluelinkFromConfig(brand string, other map[string]interface{}, settings
 
 	v := &Bluelink{
 		embed:    &cc.embed,
-		Provider: bluelink.NewProvider(api, vehicle.VehicleID, cc.Expiry, cc.Cache),
+		Provider: bluelink.NewProvider(api, vehicle, cc.Expiry, cc.Cache),
 	}
 
 	return v, nil
diff --git a/vehicle/bluelink/api.go b/vehicle/bluelink/api.go
index 5d9b102f..870708ea 100644
--- a/vehicle/bluelink/api.go
+++ b/vehicle/bluelink/api.go
@@ -13,9 +13,11 @@ import (
 )
 
 const (
-	VehiclesURL     = "vehicles"
-	StatusURL       = "vehicles/%s/status"
-	StatusLatestURL = "vehicles/%s/status/latest"
+	VehiclesURL         = "vehicles"
+	StatusURL           = "vehicles/%s/status"
+	StatusLatestURL     = "vehicles/%s/status/latest"
+	StatusURLCCS2       = "vehicles/%s/ccs2/carstatus"
+	StatusLatestURLCCS2 = "vehicles/%s/ccs2/carstatus/latest"
 )
 
 const (
@@ -51,6 +53,7 @@ func NewAPI(log *util.Logger, baseURI string, decorator func(*http.Request) erro
 
 type Vehicle struct {
 	VIN, VehicleName, VehicleID string
+	CcuCCS2ProtocolSupport      int
 }
 
 func (v *API) Vehicles() ([]Vehicle, error) {
@@ -63,27 +66,47 @@ func (v *API) Vehicles() ([]Vehicle, error) {
 }
 
 // StatusLatest retrieves the latest server-side status
-func (v *API) StatusLatest(vid string) (StatusLatestResponse, error) {
-	var res StatusLatestResponse
+func (v *API) StatusLatest(vehicle Vehicle) (BluelinkVehicleStatusLatest, error) {
+	vid := vehicle.VehicleID
+
+	if vehicle.CcuCCS2ProtocolSupport != 0 {
+		var res StatusLatestResponseCCS
+		uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURLCCS2, vid))
+		err := v.GetJSON(uri, &res)
+		if err == nil && res.RetCode != resOK {
+			err = fmt.Errorf("unexpected response: %s", res.RetCode)
+		}
+		return res, err
+	}
 
+	var res StatusLatestResponse
 	uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURL, vid))
 	err := v.GetJSON(uri, &res)
 	if err == nil && res.RetCode != resOK {
 		err = fmt.Errorf("unexpected response: %s", res.RetCode)
 	}
-
 	return res, err
 }
 
 // StatusPartial refreshes the status
-func (v *API) StatusPartial(vid string) (StatusResponse, error) {
-	var res StatusResponse
+func (v *API) StatusPartial(vehicle Vehicle) (BluelinkVehicleStatus, error) {
+	vid := vehicle.VehicleID
+
+	if vehicle.CcuCCS2ProtocolSupport != 0 {
+		var res StatusLatestResponseCCS
+		uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURLCCS2, vid))
+		err := v.GetJSON(uri, &res)
+		if err == nil && res.RetCode != resOK {
+			err = fmt.Errorf("unexpected response: %s", res.RetCode)
+		}
+		return res, err
+	}
 
+	var res StatusResponse
 	uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusURL, vid))
 	err := v.GetJSON(uri, &res)
 	if err == nil && res.RetCode != resOK {
 		err = fmt.Errorf("unexpected response: %s", res.RetCode)
 	}
-
 	return res, err
 }
diff --git a/vehicle/bluelink/provider.go b/vehicle/bluelink/provider.go
index ad7d023a..86a526f5 100644
--- a/vehicle/bluelink/provider.go
+++ b/vehicle/bluelink/provider.go
@@ -12,50 +12,50 @@ const refreshTimeout = 2 * time.Minute
 // Provider implements the vehicle api.
 // Based on https://github.com/Hacksore/bluelinky.
 type Provider struct {
-	statusG     func() (VehicleStatus, error)
-	statusLG    func() (StatusLatestResponse, error)
-	refreshG    func() (StatusResponse, error)
+	statusG     func() (BluelinkVehicleStatus, error)
+	statusLG    func() (BluelinkVehicleStatusLatest, error)
+	refreshG    func() (BluelinkVehicleStatus, error)
 	expiry      time.Duration
 	refreshTime time.Time
 }
 
 // New creates a new BlueLink API
-func NewProvider(api *API, vid string, expiry, cache time.Duration) *Provider {
+func NewProvider(api *API, vehicle Vehicle, expiry, cache time.Duration) *Provider {
 	v := &Provider{
-		refreshG: func() (StatusResponse, error) {
-			return api.StatusPartial(vid)
+		refreshG: func() (BluelinkVehicleStatus, error) {
+			return api.StatusPartial(vehicle)
 		},
 		expiry: expiry,
 	}
 
-	v.statusG = provider.Cached(func() (VehicleStatus, error) {
+	v.statusG = provider.Cached(func() (BluelinkVehicleStatus, error) {
 		return v.status(
-			func() (StatusLatestResponse, error) { return api.StatusLatest(vid) },
+			func() (BluelinkVehicleStatus, error) { return api.StatusPartial(vehicle) },
 		)
 	}, cache)
 
-	v.statusLG = provider.Cached(func() (StatusLatestResponse, error) {
-		return api.StatusLatest(vid)
+	v.statusLG = provider.Cached(func() (BluelinkVehicleStatusLatest, error) {
+		return api.StatusLatest(vehicle)
 	}, cache)
 
 	return v
 }
 
 // status wraps the api status call and adds status refresh
-func (v *Provider) status(statusG func() (StatusLatestResponse, error)) (VehicleStatus, error) {
+func (v *Provider) status(statusG func() (BluelinkVehicleStatus, error)) (BluelinkVehicleStatus, error) {
 	res, err := statusG()
 
 	var ts time.Time
 	if err == nil {
-		ts, err = res.ResMsg.VehicleStatusInfo.VehicleStatus.Updated()
+		ts, err = res.Updated()
 		if err != nil {
-			return res.ResMsg.VehicleStatusInfo.VehicleStatus, err
+			return res, err
 		}
 
 		// return the current value
 		if time.Since(ts) <= v.expiry {
 			v.refreshTime = time.Time{}
-			return res.ResMsg.VehicleStatusInfo.VehicleStatus, nil
+			return res, nil
 		}
 	}
 
@@ -66,15 +66,15 @@ func (v *Provider) status(statusG func() (StatusLatestResponse, error)) (Vehicle
 		// TODO async refresh
 		res, err := v.refreshG()
 		if err == nil {
-			if ts, err = res.ResMsg.Updated(); err == nil && time.Since(ts) <= v.expiry {
+			if ts, err = res.Updated(); err == nil && time.Since(ts) <= v.expiry {
 				v.refreshTime = time.Time{}
-				return res.ResMsg, nil
+				return res, nil
 			}
 
 			err = api.ErrMustRetry
 		}
 
-		return VehicleStatus{}, err
+		return nil, err
 	}
 
 	// refresh finally expired
@@ -88,7 +88,7 @@ func (v *Provider) status(statusG func() (StatusLatestResponse, error)) (Vehicle
 		err = api.ErrMustRetry
 	}
 
-	return VehicleStatus{}, err
+	return nil, err
 }
 
 var _ api.Battery = (*Provider)(nil)
@@ -99,12 +99,7 @@ func (v *Provider) Soc() (float64, error) {
 	if err != nil {
 		return 0, err
 	}
-
-	if res.EvStatus != nil {
-		return res.EvStatus.BatteryStatus, nil
-	}
-
-	return 0, api.ErrNotAvailable
+	return res.SoC()
 }
 
 var _ api.ChargeState = (*Provider)(nil)
@@ -112,25 +107,11 @@ var _ api.ChargeState = (*Provider)(nil)
 // Status implements the api.Battery interface
 func (v *Provider) Status() (api.ChargeStatus, error) {
 	status := api.StatusNone
-
 	res, err := v.statusG()
 	if err != nil {
 		return status, err
 	}
-
-	if res.EvStatus != nil {
-		status = api.StatusA
-		if res.EvStatus.BatteryPlugin > 0 || res.EvStatus.ChargePortDoorOpenStatus == 1 {
-			status = api.StatusB
-		}
-		if res.EvStatus.BatteryCharge {
-			status = api.StatusC
-		}
-	} else {
-		err = api.ErrNotAvailable
-	}
-
-	return status, err
+	return res.Status()
 }
 
 var _ api.VehicleFinishTimer = (*Provider)(nil)
@@ -141,19 +122,7 @@ func (v *Provider) FinishTime() (time.Time, error) {
 	if err != nil {
 		return time.Time{}, err
 	}
-
-	if res.EvStatus != nil {
-		remaining := res.EvStatus.RemainTime2.Atc.Value
-
-		if remaining == 0 {
-			return time.Time{}, api.ErrNotAvailable
-		}
-
-		ts, err := res.Updated()
-		return ts.Add(time.Duration(remaining) * time.Minute), err
-	}
-
-	return time.Time{}, api.ErrNotAvailable
+	return res.FinishTime()
 }
 
 var _ api.VehicleRange = (*Provider)(nil)
@@ -164,14 +133,7 @@ func (v *Provider) Range() (int64, error) {
 	if err != nil {
 		return 0, err
 	}
-
-	if res.EvStatus != nil {
-		if dist := res.EvStatus.DrvDistance; len(dist) == 1 {
-			return int64(dist[0].RangeByFuel.EvModeRange.Value), nil
-		}
-	}
-
-	return 0, api.ErrNotAvailable
+	return res.Range()
 }
 
 var _ api.VehicleOdometer = (*Provider)(nil)
@@ -182,12 +144,7 @@ func (v *Provider) Odometer() (float64, error) {
 	if err != nil {
 		return 0, err
 	}
-
-	if res.ResMsg.VehicleStatusInfo.Odometer != nil {
-		return res.ResMsg.VehicleStatusInfo.Odometer.Value, err
-	}
-
-	return 0, api.ErrNotAvailable
+	return res.Odometer()
 }
 
 var _ api.SocLimiter = (*Provider)(nil)
@@ -198,16 +155,7 @@ func (v *Provider) GetLimitSoc() (int64, error) {
 	if err != nil {
 		return 0, err
 	}
-
-	if res.EvStatus != nil {
-		for _, targetSOC := range res.EvStatus.ReservChargeInfos.TargetSocList {
-			if targetSOC.PlugType == plugTypeAC {
-				return int64(targetSOC.TargetSocLevel), nil
-			}
-		}
-	}
-
-	return 0, api.ErrNotAvailable
+	return res.GetLimitSoc()
 }
 
 var _ api.VehiclePosition = (*Provider)(nil)
@@ -218,13 +166,7 @@ func (v *Provider) Position() (float64, float64, error) {
 	if err != nil {
 		return 0, 0, err
 	}
-
-	if res.ResMsg.VehicleStatusInfo.VehicleLocation != nil {
-		coord := res.ResMsg.VehicleStatusInfo.VehicleLocation.Coord
-		return coord.Lat, coord.Lon, err
-	}
-
-	return 0, 0, api.ErrNotAvailable
+	return res.Position()
 }
 
 var _ api.Resurrector = (*Provider)(nil)
diff --git a/vehicle/bluelink/types.go b/vehicle/bluelink/types.go
index fd658cba..13e55768 100644
--- a/vehicle/bluelink/types.go
+++ b/vehicle/bluelink/types.go
@@ -1,6 +1,25 @@
 package bluelink
 
-import "time"
+import (
+	"strconv"
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+)
+
+type BluelinkVehicleStatus interface {
+	Updated() (time.Time, error)
+	SoC() (float64, error)
+	Status() (api.ChargeStatus, error)
+	FinishTime() (time.Time, error)
+	Range() (int64, error)
+	GetLimitSoc() (int64, error)
+}
+
+type BluelinkVehicleStatusLatest interface {
+	Odometer() (float64, error)
+	Position() (float64, float64, error)
+}
 
 type VehiclesResponse struct {
 	RetCode string
@@ -84,3 +103,235 @@ type TargetSoc struct {
 	TargetSocLevel int
 	PlugType       int
 }
+
+func (d StatusResponse) Updated() (time.Time, error) {
+	return time.Parse(timeFormat, d.ResMsg.Time+timeOffset)
+}
+
+func (d StatusResponse) SoC() (float64, error) {
+	if d.ResMsg.EvStatus != nil {
+		return d.ResMsg.EvStatus.BatteryStatus, nil
+	}
+
+	return 0, api.ErrNotAvailable
+}
+
+func (d StatusResponse) Status() (api.ChargeStatus, error) {
+	if d.ResMsg.EvStatus != nil {
+		status := api.StatusA
+		if d.ResMsg.EvStatus.BatteryPlugin > 0 || d.ResMsg.EvStatus.ChargePortDoorOpenStatus == 1 {
+			status = api.StatusB
+		}
+		if d.ResMsg.EvStatus.BatteryCharge {
+			status = api.StatusC
+		}
+		return status, nil
+	}
+
+	return api.StatusNone, api.ErrNotAvailable
+}
+
+func (d StatusResponse) FinishTime() (time.Time, error) {
+	if d.ResMsg.EvStatus != nil {
+		remaining := d.ResMsg.EvStatus.RemainTime2.Atc.Value
+
+		if remaining != 0 {
+			ts, err := d.ResMsg.Updated()
+			return ts.Add(time.Duration(remaining) * time.Minute), err
+		}
+	}
+
+	return time.Time{}, api.ErrNotAvailable
+}
+
+func (d StatusResponse) Range() (int64, error) {
+	if d.ResMsg.EvStatus != nil {
+		if dist := d.ResMsg.EvStatus.DrvDistance; len(dist) == 1 {
+			return int64(dist[0].RangeByFuel.EvModeRange.Value), nil
+		}
+	}
+	return 0, api.ErrNotAvailable
+}
+
+func (d StatusResponse) GetLimitSoc() (int64, error) {
+	if d.ResMsg.EvStatus != nil {
+		for _, targetSOC := range d.ResMsg.EvStatus.ReservChargeInfos.TargetSocList {
+			if targetSOC.PlugType == plugTypeAC {
+				return int64(targetSOC.TargetSocLevel), nil
+			}
+		}
+	}
+	return 0, api.ErrNotAvailable
+}
+
+func (d StatusLatestResponse) Odometer() (float64, error) {
+	if d.ResMsg.VehicleStatusInfo.Odometer != nil {
+		return d.ResMsg.VehicleStatusInfo.Odometer.Value, nil
+	}
+	return 0, api.ErrNotAvailable
+}
+
+func (d StatusLatestResponse) Position() (float64, float64, error) {
+	if d.ResMsg.VehicleStatusInfo.VehicleLocation != nil {
+		pos := d.ResMsg.VehicleStatusInfo.VehicleLocation.Coord
+		return pos.Lat, pos.Lon, nil
+	}
+	return 0, 0, api.ErrNotAvailable
+}
+
+type StatusLatestResponseCCS struct {
+	RetCode string
+	ResCode string
+	ResMsg  struct {
+		State struct {
+			Vehicle VehicleStatusCCS
+		}
+		LastUpdateTime string
+	}
+}
+
+type VehicleStatusCCS struct {
+	Location *struct {
+		GeoCoord struct {
+			Latitude, Longitude, Altitude float64
+			Type                          int
+			Date                          string
+		}
+	}
+	Green *struct {
+		BatteryManagement struct {
+			BatteryRemain struct {
+				Ratio float64
+				Value float64
+			}
+			BatteryCapacity struct {
+				Value float64
+			}
+			SoH struct {
+				Ratio float64
+			}
+		}
+		ChargingInformation struct {
+			ConnectorFastening struct {
+				// 1 connected
+				State int
+			}
+			Charging struct {
+				RemainTime     float64
+				RemainTimeUnit int
+			}
+			EstimatedTime struct {
+				Standard float64
+				ICCB     float64
+				Quick    float64
+				Unit     int
+			}
+			ExpectedTime struct {
+				StartDay  int
+				StartHour int
+				StartMin  int
+				EndDay    int
+				EndHour   int
+				EndMin    int
+			}
+			TargetSoC struct {
+				Standard int64
+				Quick    int64
+			}
+			DTE struct {
+				TargetSoC struct {
+					// in Drivetrain.FuelSystem.DTE.Unit
+					Standard float64
+					Quick    float64
+				}
+			}
+		}
+		ChargingDoor struct {
+			// 0, 2 closed, 1 open
+			State int
+		}
+		Electric struct {
+			SmartGrid struct {
+				VehicleToLoad struct {
+					DischargeLimitation struct {
+						SoC        float64
+						RemainTime float64
+					}
+				}
+			}
+		}
+	}
+	Drivetrain struct {
+		Odometer   float64
+		FuelSystem struct {
+			DTE struct {
+				Total int64
+			}
+		}
+	}
+}
+
+func (d StatusLatestResponseCCS) Updated() (time.Time, error) {
+	epoch, err := strconv.ParseInt(d.ResMsg.LastUpdateTime, 10, 64)
+	if err != nil {
+		return time.Now(), err
+	}
+	return time.UnixMilli(epoch), nil
+}
+
+func (d StatusLatestResponseCCS) SoC() (float64, error) {
+	if d.ResMsg.State.Vehicle.Green != nil {
+		return d.ResMsg.State.Vehicle.Green.BatteryManagement.BatteryRemain.Ratio, nil
+	}
+	return 0, api.ErrNotAvailable
+}
+
+func (d StatusLatestResponseCCS) Status() (api.ChargeStatus, error) {
+	if d.ResMsg.State.Vehicle.Green != nil {
+		if d.ResMsg.State.Vehicle.Green.ChargingInformation.ConnectorFastening.State == 1 {
+			return api.StatusB, nil
+		}
+		if d.ResMsg.State.Vehicle.Green.ChargingInformation.Charging.RemainTime > 0 {
+			return api.StatusC, nil
+		}
+		return api.StatusA, nil
+	}
+	return api.StatusNone, api.ErrNotAvailable
+}
+
+func (d StatusLatestResponseCCS) FinishTime() (time.Time, error) {
+	if d.ResMsg.State.Vehicle.Green != nil {
+		remaining := d.ResMsg.State.Vehicle.Green.ChargingInformation.Charging.RemainTime
+
+		if remaining == 0 {
+			return time.Time{}, api.ErrNotAvailable
+		}
+
+		ts, err := d.Updated()
+		return ts.Add(time.Duration(remaining) * time.Minute), err
+	}
+
+	return time.Now(), api.ErrNotAvailable
+}
+
+func (d StatusLatestResponseCCS) Range() (int64, error) {
+	return d.ResMsg.State.Vehicle.Drivetrain.FuelSystem.DTE.Total, nil
+}
+
+func (d StatusLatestResponseCCS) GetLimitSoc() (int64, error) {
+	if d.ResMsg.State.Vehicle.Green != nil {
+		return d.ResMsg.State.Vehicle.Green.ChargingInformation.TargetSoC.Standard, nil
+	}
+	return 0, api.ErrNotAvailable
+}
+
+func (d StatusLatestResponseCCS) Odometer() (float64, error) {
+	return d.ResMsg.State.Vehicle.Drivetrain.Odometer, nil
+}
+
+func (d StatusLatestResponseCCS) Position() (float64, float64, error) {
+	if d.ResMsg.State.Vehicle.Location != nil {
+		return d.ResMsg.State.Vehicle.Location.GeoCoord.Latitude, d.ResMsg.State.Vehicle.Location.GeoCoord.Longitude, nil
+	}
+	return 0, 0, api.ErrNotAvailable
+}


Test patch:
diff --git a/server/mqtt_test.go b/server/mqtt_test.go
index 1ebe08b7..a7dd1347 100644
--- a/server/mqtt_test.go
+++ b/server/mqtt_test.go
@@ -42,15 +42,25 @@ func TestPublishTypes(t *testing.T) {
 	}{
 		Foo: "bar",
 	})
-	require.Len(t, topics, 1)
-	assert.Equal(t, `test/foo`, topics[0], "struct mismatch")
-	assert.Equal(t, `bar`, payloads[0], "struct mismatch")
+	assert.Equal(t, []string{"test/foo"}, topics, "struct mismatch")
+	assert.Equal(t, []string{"bar"}, payloads, "struct mismatch")
+	reset()
+
+	i := 1
+	m.publish("test", false, struct {
+		Foo, Bar *int
+	}{
+		Foo: &i,
+		Bar: nil,
+	})
+	assert.Equal(t, []string{"test/foo", "test/bar"}, topics, "pointer mismatch")
+	assert.Equal(t, []string{"1", ""}, payloads, "pointer mismatch")
 	reset()
 
 	slice := []int{10, 20}
 	m.publish("test", false, slice)
 	require.Len(t, topics, 3)
-	assert.Equal(t, []string{`test`, `test/1`, `test/2`}, topics, "slice mismatch")
-	assert.Equal(t, []string{`2`, `10`, `20`}, payloads, "slice mismatch")
+	assert.Equal(t, []string{"test", "test/1", "test/2"}, topics, "slice mismatch")
+	assert.Equal(t, []string{"2", "10", "20"}, payloads, "slice mismatch")
 	reset()
 }