diff --git a/charger/trydan.go b/charger/trydan.go index 05dbeceda..9cf496dee 100644 --- a/charger/trydan.go +++ b/charger/trydan.go @@ -32,28 +32,45 @@ import ( ) type RealTimeData struct { - ID string `json:"ID"` - ChargeState int `json:"ChargeState"` - ReadyState int `json:"ReadyState"` - ChargePower float64 `json:"ChargePower"` - ChargeEnergy float64 `json:"ChargeEnergy"` - SlaveError int `json:"SlaveError"` - ChargeTime int `json:"ChargeTime"` - HousePower float64 `json:"HousePower"` - FVPower float64 `json:"FVPower"` - BatteryPower float64 `json:"BatteryPower"` - Paused int `json:"Paused"` - Locked int `json:"Locked"` - Timer int `json:"Timer"` - Intensity int `json:"Intensity"` - Dynamic int `json:"Dynamic"` - MinIntensity int `json:"MinIntensity"` - MaxIntensity int `json:"MaxIntensity"` - PauseDynamic int `json:"PauseDynamic"` - FirmwareVersion string `json:"FirmwareVersion"` - DynamicPowerMode int `json:"DynamicPowerMode"` - ContractedPower int `json:"ContractedPower"` - ChargeMode int `json:"ChargeMode"` + ID string `json:"ID"` + ChargeState int `json:"ChargeState"` + ReadyState int `json:"ReadyState"` + ChargePower float64 `json:"ChargePower"` + ChargeEnergy float64 `json:"ChargeEnergy"` + SlaveError int `json:"SlaveError"` + ChargeTime int `json:"ChargeTime"` + HousePower float64 `json:"HousePower"` + FVPower float64 `json:"FVPower"` + BatteryPower float64 `json:"BatteryPower"` + Paused int `json:"Paused"` + Locked int `json:"Locked"` + Timer int `json:"Timer"` + Intensity int `json:"Intensity"` + Dynamic int `json:"Dynamic"` + MinIntensity int `json:"MinIntensity"` + MaxIntensity int `json:"MaxIntensity"` + PauseDynamic int `json:"PauseDynamic"` + FirmwareVersion string `json:"FirmwareVersion"` + DynamicPowerMode int `json:"DynamicPowerMode"` + ContractedPower int `json:"ContractedPower"` + 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 @@ -68,8 +85,6 @@ type Trydan struct { *request.Helper uri string statusG util.Cacheable[RealTimeData] - current int - enabled bool } func init() { @@ -114,6 +129,20 @@ func NewTrydan(uri string, cache time.Duration) (api.Charger, error) { return res, err }, 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 } @@ -129,6 +158,10 @@ func (t Trydan) Status() (api.ChargeStatus, error) { case 1: return api.StatusB, nil case 2: + // firmware keeps ChargeState at "charging" even after Paused=1 + if data.Paused == 1 { + return api.StatusB, nil + } return api.StatusC, nil default: 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 func (c Trydan) Enabled() (bool, error) { 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 { @@ -152,45 +185,17 @@ func (c *Trydan) setValue(param string, value int) error { // Enable implements the api.Charger interface func (c Trydan) Enable(enable bool) error { - var pause, pauseDynamic int + var pause int if !enable { pause = 1 - } else { - pauseDynamic = 1 } - if err := c.setValue("Paused", pause); err != nil { - 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 + return c.setValue("Paused", pause) } // MaxCurrent implements the api.Charger interface func (c Trydan) MaxCurrent(current int64) error { - err := c.setValue("Intensity", int(current)) - if err == nil { - c.current = int(current) - } - return err + return c.setValue("Intensity", int(current)) } // 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 } +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) // Diagnose implements the api.Diagnosis interface func (c *Trydan) Diagnose() { data, err := c.statusG.Get() 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) } diff --git a/charger/trydan_test.go b/charger/trydan_test.go new file mode 100644 index 000000000..368625006 --- /dev/null +++ b/charger/trydan_test.go @@ -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) + } + }) + } +}