Trydan: remove Locked, pause DPC, add currents/voltages (firmware >=2.5.0) (#32013)
Co-authored-by: blastik <3662083+blastik@users.noreply.github.com> Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
This commit is contained in:
parent
209b72b817
commit
1b16f23da3
2 changed files with 250 additions and 57 deletions
|
|
@ -32,28 +32,45 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type RealTimeData struct {
|
type RealTimeData struct {
|
||||||
ID string `json:"ID"`
|
ID string `json:"ID"`
|
||||||
ChargeState int `json:"ChargeState"`
|
ChargeState int `json:"ChargeState"`
|
||||||
ReadyState int `json:"ReadyState"`
|
ReadyState int `json:"ReadyState"`
|
||||||
ChargePower float64 `json:"ChargePower"`
|
ChargePower float64 `json:"ChargePower"`
|
||||||
ChargeEnergy float64 `json:"ChargeEnergy"`
|
ChargeEnergy float64 `json:"ChargeEnergy"`
|
||||||
SlaveError int `json:"SlaveError"`
|
SlaveError int `json:"SlaveError"`
|
||||||
ChargeTime int `json:"ChargeTime"`
|
ChargeTime int `json:"ChargeTime"`
|
||||||
HousePower float64 `json:"HousePower"`
|
HousePower float64 `json:"HousePower"`
|
||||||
FVPower float64 `json:"FVPower"`
|
FVPower float64 `json:"FVPower"`
|
||||||
BatteryPower float64 `json:"BatteryPower"`
|
BatteryPower float64 `json:"BatteryPower"`
|
||||||
Paused int `json:"Paused"`
|
Paused int `json:"Paused"`
|
||||||
Locked int `json:"Locked"`
|
Locked int `json:"Locked"`
|
||||||
Timer int `json:"Timer"`
|
Timer int `json:"Timer"`
|
||||||
Intensity int `json:"Intensity"`
|
Intensity int `json:"Intensity"`
|
||||||
Dynamic int `json:"Dynamic"`
|
Dynamic int `json:"Dynamic"`
|
||||||
MinIntensity int `json:"MinIntensity"`
|
MinIntensity int `json:"MinIntensity"`
|
||||||
MaxIntensity int `json:"MaxIntensity"`
|
MaxIntensity int `json:"MaxIntensity"`
|
||||||
PauseDynamic int `json:"PauseDynamic"`
|
PauseDynamic int `json:"PauseDynamic"`
|
||||||
FirmwareVersion string `json:"FirmwareVersion"`
|
FirmwareVersion string `json:"FirmwareVersion"`
|
||||||
DynamicPowerMode int `json:"DynamicPowerMode"`
|
DynamicPowerMode int `json:"DynamicPowerMode"`
|
||||||
ContractedPower int `json:"ContractedPower"`
|
ContractedPower int `json:"ContractedPower"`
|
||||||
ChargeMode int `json:"ChargeMode"`
|
ChargeMode int `json:"ChargeMode"`
|
||||||
|
IntensityMeasureL1 float64 `json:"IntensityMeasure_L1"`
|
||||||
|
IntensityMeasureL2 float64 `json:"IntensityMeasure_L2"`
|
||||||
|
IntensityMeasureL3 float64 `json:"IntensityMeasure_L3"`
|
||||||
|
VoltageMeasureL1 float64 `json:"VoltageMeasure_L1"`
|
||||||
|
VoltageMeasureL2 float64 `json:"VoltageMeasure_L2"`
|
||||||
|
VoltageMeasureL3 float64 `json:"VoltageMeasure_L3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// phaseMeasurementsUnavailable reports whether the firmware does not populate the per-phase
|
||||||
|
// current/voltage fields (added in 2.5.0; older firmware just omits them, unmarshalling to
|
||||||
|
// zero). A grid-connected charger always sees mains voltage, so an all-zero voltage sum can
|
||||||
|
// only mean the fields are missing. Currents are checked additionally while power is flowing,
|
||||||
|
// where an all-zero reading is equally impossible; zero currents while idle are legitimate.
|
||||||
|
func (data RealTimeData) phaseMeasurementsUnavailable() bool {
|
||||||
|
return data.VoltageMeasureL1+data.VoltageMeasureL2+data.VoltageMeasureL3 == 0 ||
|
||||||
|
data.ChargePower > 0 &&
|
||||||
|
data.IntensityMeasureL1+data.IntensityMeasureL2+data.IntensityMeasureL3 == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trydan ChargeMode values
|
// Trydan ChargeMode values
|
||||||
|
|
@ -68,8 +85,6 @@ type Trydan struct {
|
||||||
*request.Helper
|
*request.Helper
|
||||||
uri string
|
uri string
|
||||||
statusG util.Cacheable[RealTimeData]
|
statusG util.Cacheable[RealTimeData]
|
||||||
current int
|
|
||||||
enabled bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -114,6 +129,20 @@ func NewTrydan(uri string, cache time.Duration) (api.Charger, error) {
|
||||||
return res, err
|
return res, err
|
||||||
}, cache)
|
}, cache)
|
||||||
|
|
||||||
|
data, err := c.statusG.Get()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanently pause the chargers internal Dynamic Power Control to let evcc take over
|
||||||
|
// charging power control. It is not disabled entirely since the charger would then stop
|
||||||
|
// returning power readings.
|
||||||
|
if data.Dynamic == 1 {
|
||||||
|
if err := c.setValue("PauseDynamic", 1); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,6 +158,10 @@ func (t Trydan) Status() (api.ChargeStatus, error) {
|
||||||
case 1:
|
case 1:
|
||||||
return api.StatusB, nil
|
return api.StatusB, nil
|
||||||
case 2:
|
case 2:
|
||||||
|
// firmware keeps ChargeState at "charging" even after Paused=1
|
||||||
|
if data.Paused == 1 {
|
||||||
|
return api.StatusB, nil
|
||||||
|
}
|
||||||
return api.StatusC, nil
|
return api.StatusC, nil
|
||||||
default:
|
default:
|
||||||
return api.StatusNone, fmt.Errorf("unknown status: %d", state)
|
return api.StatusNone, fmt.Errorf("unknown status: %d", state)
|
||||||
|
|
@ -138,7 +171,7 @@ func (t Trydan) Status() (api.ChargeStatus, error) {
|
||||||
// Enabled implements the api.Charger interface
|
// Enabled implements the api.Charger interface
|
||||||
func (c Trydan) Enabled() (bool, error) {
|
func (c Trydan) Enabled() (bool, error) {
|
||||||
data, err := c.statusG.Get()
|
data, err := c.statusG.Get()
|
||||||
return data.Paused == 0 && data.Locked == 0, err
|
return data.Paused == 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Trydan) setValue(param string, value int) error {
|
func (c *Trydan) setValue(param string, value int) error {
|
||||||
|
|
@ -152,45 +185,17 @@ func (c *Trydan) setValue(param string, value int) error {
|
||||||
|
|
||||||
// Enable implements the api.Charger interface
|
// Enable implements the api.Charger interface
|
||||||
func (c Trydan) Enable(enable bool) error {
|
func (c Trydan) Enable(enable bool) error {
|
||||||
var pause, pauseDynamic int
|
var pause int
|
||||||
if !enable {
|
if !enable {
|
||||||
pause = 1
|
pause = 1
|
||||||
} else {
|
|
||||||
pauseDynamic = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.setValue("Paused", pause); err != nil {
|
return c.setValue("Paused", pause)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := c.setValue("Locked", pause); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Pause/Unpause Dynamic Power Control if enabled.
|
|
||||||
// This is needed to let EVCC taking over charging power control.
|
|
||||||
// Charger will stop returning power readings if 'Dynamic' is disabled.
|
|
||||||
data, err := c.statusG.Get()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.Dynamic == 1 {
|
|
||||||
if err := c.setValue("PauseDynamic", pauseDynamic); err != nil {
|
|
||||||
// Pause V2C 'PauseDynamic' when EVCC charging is active and vice versa.
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.enabled = enable
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxCurrent implements the api.Charger interface
|
// MaxCurrent implements the api.Charger interface
|
||||||
func (c Trydan) MaxCurrent(current int64) error {
|
func (c Trydan) MaxCurrent(current int64) error {
|
||||||
err := c.setValue("Intensity", int(current))
|
return c.setValue("Intensity", int(current))
|
||||||
if err == nil {
|
|
||||||
c.current = int(current)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// removed broken interfaces https://github.com/evcc-io/evcc/issues/28047
|
// removed broken interfaces https://github.com/evcc-io/evcc/issues/28047
|
||||||
|
|
@ -250,12 +255,52 @@ func (c Trydan) CurrentPower() (float64, error) {
|
||||||
return data.ChargePower, err
|
return data.ChargePower, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ api.PhaseCurrents = (*Trydan)(nil)
|
||||||
|
|
||||||
|
// Currents implements the api.PhaseCurrents interface
|
||||||
|
func (c Trydan) Currents() (float64, float64, float64, error) {
|
||||||
|
data, err := c.statusG.Get()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if data.phaseMeasurementsUnavailable() {
|
||||||
|
return 0, 0, 0, api.ErrNotAvailable
|
||||||
|
}
|
||||||
|
return data.IntensityMeasureL1, data.IntensityMeasureL2, data.IntensityMeasureL3, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.PhaseVoltages = (*Trydan)(nil)
|
||||||
|
|
||||||
|
// Voltages implements the api.PhaseVoltages interface
|
||||||
|
func (c Trydan) Voltages() (float64, float64, float64, error) {
|
||||||
|
data, err := c.statusG.Get()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if data.phaseMeasurementsUnavailable() {
|
||||||
|
return 0, 0, 0, api.ErrNotAvailable
|
||||||
|
}
|
||||||
|
return data.VoltageMeasureL1, data.VoltageMeasureL2, data.VoltageMeasureL3, nil
|
||||||
|
}
|
||||||
|
|
||||||
var _ api.Diagnosis = (*Trydan)(nil)
|
var _ api.Diagnosis = (*Trydan)(nil)
|
||||||
|
|
||||||
// Diagnose implements the api.Diagnosis interface
|
// Diagnose implements the api.Diagnosis interface
|
||||||
func (c *Trydan) Diagnose() {
|
func (c *Trydan) Diagnose() {
|
||||||
data, err := c.statusG.Get()
|
data, err := c.statusG.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("%#v", data)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\tID:\t%s\n", data.ID)
|
||||||
|
fmt.Printf("\tFirmware:\t%s\n", data.FirmwareVersion)
|
||||||
|
fmt.Printf("\tCharge mode:\t%d\n", data.ChargeMode)
|
||||||
|
fmt.Printf("\tMin current:\t%dA\n", data.MinIntensity)
|
||||||
|
fmt.Printf("\tMax current:\t%dA\n", data.MaxIntensity)
|
||||||
|
fmt.Printf("\tContracted power:\t%dW\n", data.ContractedPower)
|
||||||
|
fmt.Printf("\tDynamic:\t%d\n", data.Dynamic)
|
||||||
|
fmt.Printf("\tPause dynamic:\t%d\n", data.PauseDynamic)
|
||||||
|
fmt.Printf("\tDynamic power mode:\t%d\n", data.DynamicPowerMode)
|
||||||
|
fmt.Printf("\tLocked:\t%d\n", data.Locked)
|
||||||
|
fmt.Printf("\tSlave error:\t%d\n", data.SlaveError)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
148
charger/trydan_test.go
Normal file
148
charger/trydan_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package charger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/api"
|
||||||
|
"github.com/evcc-io/evcc/util/sponsor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withSponsor authorizes the sponsor-gated Trydan charger for the duration of the test,
|
||||||
|
// restoring the previous value afterwards so tests don't leak global state between them.
|
||||||
|
func withSponsor(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
orig := sponsor.Subject
|
||||||
|
sponsor.Subject = "foo"
|
||||||
|
t.Cleanup(func() { sponsor.Subject = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func trydanTestServerWithBody(body string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, body)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChargeState maps directly to api.ChargeStatus, except firmware 2.5.0 keeps it at
|
||||||
|
// "charging" even after Paused=1, so that specific combination must fall back to StatusB.
|
||||||
|
func TestTrydanStatus(t *testing.T) {
|
||||||
|
withSponsor(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
want api.ChargeStatus
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"not connected", `{"ChargeState":0,"Paused":0}`, api.StatusA, false},
|
||||||
|
{"connected, not charging", `{"ChargeState":1,"Paused":0}`, api.StatusB, false},
|
||||||
|
{"charging", `{"ChargeState":2,"Paused":0}`, api.StatusC, false},
|
||||||
|
// firmware 2.5.0 keeps ChargeState at "charging" even after Paused=1
|
||||||
|
{"paused mid-session", `{"ChargeState":2,"Paused":1}`, api.StatusB, false},
|
||||||
|
{"unknown state", `{"ChargeState":9,"Paused":0}`, api.StatusNone, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := trydanTestServerWithBody(tc.json)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wb, err := NewTrydan(srv.URL, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := wb.Status()
|
||||||
|
if tc.wantErr != (err != nil) {
|
||||||
|
t.Fatalf("got err %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
if status != tc.want {
|
||||||
|
t.Errorf("got status %v, want %v", status, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currents() must trust a zero reading while idle, but treat it as unavailable
|
||||||
|
// (older firmware without these fields) whenever real power is flowing, since
|
||||||
|
// ChargePower>0 with all phases at zero is otherwise physically impossible.
|
||||||
|
// Voltages() is always unavailable when all phases read zero, since a
|
||||||
|
// grid-connected charger always sees mains voltage.
|
||||||
|
func TestTrydanPhaseMeasurementsUnavailable(t *testing.T) {
|
||||||
|
withSponsor(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
wantUnavailable bool
|
||||||
|
wantCurrentL1 float64
|
||||||
|
wantVoltageL1 float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "idle, zero current readings are trusted",
|
||||||
|
json: `{"ChargeState":1,"ChargePower":0,
|
||||||
|
"IntensityMeasure_L1":0,"IntensityMeasure_L2":0,"IntensityMeasure_L3":0,
|
||||||
|
"VoltageMeasure_L1":230.2,"VoltageMeasure_L2":229.8,"VoltageMeasure_L3":231.1}`,
|
||||||
|
wantVoltageL1: 230.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "charging but all phases read zero - unsupported firmware",
|
||||||
|
json: `{"ChargeState":2,"ChargePower":4600,
|
||||||
|
"IntensityMeasure_L1":0,"IntensityMeasure_L2":0,"IntensityMeasure_L3":0,
|
||||||
|
"VoltageMeasure_L1":0,"VoltageMeasure_L2":0,"VoltageMeasure_L3":0}`,
|
||||||
|
wantUnavailable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "charging with real per-phase readings",
|
||||||
|
json: `{"ChargeState":2,"ChargePower":4600,
|
||||||
|
"IntensityMeasure_L1":20.5,"IntensityMeasure_L2":0,"IntensityMeasure_L3":0,
|
||||||
|
"VoltageMeasure_L1":228.1,"VoltageMeasure_L2":0,"VoltageMeasure_L3":0}`,
|
||||||
|
wantCurrentL1: 20.5,
|
||||||
|
wantVoltageL1: 228.1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := trydanTestServerWithBody(tc.json)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wb, err := NewTrydan(srv.URL, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pc, ok := api.Cap[api.PhaseCurrents](wb)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing api.PhaseCurrents")
|
||||||
|
}
|
||||||
|
pv, ok := api.Cap[api.PhaseVoltages](wb)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing api.PhaseVoltages")
|
||||||
|
}
|
||||||
|
|
||||||
|
i1, _, _, err := pc.Currents()
|
||||||
|
switch {
|
||||||
|
case tc.wantUnavailable && !errors.Is(err, api.ErrNotAvailable):
|
||||||
|
t.Errorf("Currents: got err %v, want ErrNotAvailable", err)
|
||||||
|
case !tc.wantUnavailable && err != nil:
|
||||||
|
t.Fatal(err)
|
||||||
|
case !tc.wantUnavailable && i1 != tc.wantCurrentL1:
|
||||||
|
t.Errorf("Currents: got L1=%v, want %v", i1, tc.wantCurrentL1)
|
||||||
|
}
|
||||||
|
|
||||||
|
v1, _, _, err := pv.Voltages()
|
||||||
|
switch {
|
||||||
|
case tc.wantUnavailable && !errors.Is(err, api.ErrNotAvailable):
|
||||||
|
t.Errorf("Voltages: got err %v, want ErrNotAvailable", err)
|
||||||
|
case !tc.wantUnavailable && err != nil:
|
||||||
|
t.Fatal(err)
|
||||||
|
case !tc.wantUnavailable && v1 != tc.wantVoltageL1:
|
||||||
|
t.Errorf("Voltages: got L1=%v, want %v", v1, tc.wantVoltageL1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue