diff --git a/charger/tasmota.go b/charger/tasmota.go index b446ffa4d..256443c37 100644 --- a/charger/tasmota.go +++ b/charger/tasmota.go @@ -65,13 +65,19 @@ func NewTasmota(embed embed, uri, user, password, usage string, channels []int, c.switchSocket = NewSwitchSocket(&embed, c.Enabled, c.conn.CurrentPower, standbypower) - var currents, voltages func() (float64, float64, float64, error) - if len(channels) == 3 { - currents = c.currents - voltages = c.voltages + // check if phase specific readings are supported by the device, if not return the base meter implementation without decorators + var hasPhases bool + if len(channels) == 1 { + if l1, l2, l3, err := c.conn.Voltages(); err == nil && l1*l2*l3 > 0 { + hasPhases = true + } } - return decorateTasmota(c, currents, voltages), nil + if hasPhases || len(channels) == 3 { + return decorateTasmota(c, c.voltages, c.currents), nil + } + + return c, nil } // Enabled implements the api.Charger interface diff --git a/meter/tasmota.go b/meter/tasmota.go index 04a69807b..5ddf30aba 100644 --- a/meter/tasmota.go +++ b/meter/tasmota.go @@ -21,7 +21,7 @@ func init() { registry.Add("tasmota", NewTasmotaFromConfig) } -//go:generate go tool decorate -f decorateTasmota -b *Tasmota -r api.Meter -t "api.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" +//go:generate go tool decorate -f decorateTasmota -b *Tasmota -r api.Meter -t "api.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.PhasePowers,Powers,func() (float64, float64, float64, error)" // NewTasmotaFromConfig creates a Tasmota meter from generic config func NewTasmotaFromConfig(other map[string]any) (api.Meter, error) { @@ -56,13 +56,19 @@ func NewTasmota(uri, user, password, usage string, channels []int, cache time.Du usage: usage, } - var currents, voltages func() (float64, float64, float64, error) - if usage != "grid" && len(channels) == 3 { - currents = c.currents - voltages = c.voltages + // check for SML readings + var hasPhases bool + if len(channels) == 1 { + if l1, l2, l3, err := c.conn.Voltages(); err == nil && l1*l2*l3 > 0 { + hasPhases = true + } } - return decorateTasmota(c, voltages, currents), nil + if hasPhases || len(channels) == 3 { + return decorateTasmota(c, c.voltages, c.currents, c.powers), nil + } + + return c, nil } var _ api.Meter = (*Tasmota)(nil) @@ -87,12 +93,17 @@ func (c *Tasmota) TotalEnergy() (float64, error) { return c.conn.TotalEnergy() } -// currents implements the api.PhaseCurrents interface -func (c *Tasmota) currents() (float64, float64, float64, error) { - return c.conn.Currents() +// powers implements the api.PhasePowers interface +func (c *Tasmota) powers() (float64, float64, float64, error) { + return c.conn.Powers() } // voltages implements the api.PhaseVoltages interface func (c *Tasmota) voltages() (float64, float64, float64, error) { return c.conn.Voltages() } + +// currents implements the api.PhaseCurrents interface +func (c *Tasmota) currents() (float64, float64, float64, error) { + return c.conn.Currents() +} diff --git a/meter/tasmota/connection.go b/meter/tasmota/connection.go index f94b58d86..2339a0157 100644 --- a/meter/tasmota/connection.go +++ b/meter/tasmota/connection.go @@ -27,6 +27,10 @@ func NewConnection(uri, user, password string, channels []int, cache time.Durati return nil, errors.New("missing uri") } + if l := len(channels); l != 1 && l != 3 { + return nil, fmt.Errorf("invalid number of channels: %d", l) + } + used := make(map[int]bool) for _, c := range channels { if c < 1 || c > 8 { @@ -200,49 +204,91 @@ func (c *Connection) CurrentPower() (float64, error) { if err != nil { return 0, err } + + // SML power available + if sml := s.StatusSNS.SML.PowerCurr; sml != nil { + return *sml, nil + } + var res float64 for _, channel := range c.channels { - power, err := s.StatusSNS.Energy.Power.Channel(channel) + power, err := s.StatusSNS.Energy.Power.Value(channel) if err != nil { return 0, err } res += power } - return res + float64(s.StatusSNS.SML.PowerCurr), nil + + return res, nil } // TotalEnergy implements the api.MeterEnergy interface func (c *Connection) TotalEnergy() (float64, error) { res, err := c.statusSnsG.Get() - return res.StatusSNS.Energy.Total + res.StatusSNS.SML.TotalIn, err + if err != nil { + return 0, err + } + + // SML total energy available + if sml := res.StatusSNS.SML.TotalIn; sml != nil { + return *sml, err + } + + return res.StatusSNS.Energy.Total, err } -// Currents implements the api.PhaseCurrents interface -func (c *Connection) Currents() (float64, float64, float64, error) { - return c.getPhaseValues(func(s StatusSNSResponse) Channels { - return s.StatusSNS.Energy.Current - }) -} - -// Voltages implements the api.PhaseVoltages interface -func (c *Connection) Voltages() (float64, float64, float64, error) { - return c.getPhaseValues(func(s StatusSNSResponse) Channels { - return s.StatusSNS.Energy.Voltage - }) -} - -// getPhaseValues returns 3 sequential phase values -func (c *Connection) getPhaseValues(fun func(StatusSNSResponse) Channels) (float64, float64, float64, error) { +// Powers implements the api.PhasePowers interface +func (c *Connection) Powers() (float64, float64, float64, error) { s, err := c.statusSnsG.Get() if err != nil { return 0, 0, 0, err } - all := fun(s) + // SML powers available + if sml := s.StatusSNS.SML; sml.PowerL1 != nil && sml.PowerL2 != nil && sml.PowerL3 != nil { + return *sml.PowerL1, *sml.PowerL2, *sml.PowerL3, nil + } + return c.getPhaseValues(s.StatusSNS.Energy.Power) +} + +// Voltages implements the api.PhaseVoltages interface +func (c *Connection) Voltages() (float64, float64, float64, error) { + s, err := c.statusSnsG.Get() + if err != nil { + return 0, 0, 0, err + } + + // SML voltages available + if sml := s.StatusSNS.SML; sml.VoltageL1 != nil && sml.VoltageL2 != nil && sml.VoltageL3 != nil { + return *sml.VoltageL1, *sml.VoltageL2, *sml.VoltageL3, nil + } + + return c.getPhaseValues(s.StatusSNS.Energy.Voltage) +} + +// Currents implements the api.PhaseCurrents interface +func (c *Connection) Currents() (float64, float64, float64, error) { + s, err := c.statusSnsG.Get() + if err != nil { + return 0, 0, 0, err + } + + // SML currents available + if sml := s.StatusSNS.SML; sml.CurrentL1 != nil && sml.CurrentL2 != nil && sml.CurrentL3 != nil { + return *sml.CurrentL1, *sml.CurrentL2, *sml.CurrentL3, nil + } + + return c.getPhaseValues(s.StatusSNS.Energy.Current) +} + +// getPhaseValues returns 3 sequential phase values +func (c *Connection) getPhaseValues(all Channels) (float64, float64, float64, error) { var res [3]float64 - for i := range res { - res[i], err = all.Channel(c.channels[i]) + + for i, cc := range c.channels { + var err error + res[i], err = all.Value(cc) if err != nil { return 0, 0, 0, err } diff --git a/meter/tasmota/types.go b/meter/tasmota/types.go index e8cba524e..3ebb9ef2e 100644 --- a/meter/tasmota/types.go +++ b/meter/tasmota/types.go @@ -85,9 +85,18 @@ type StatusSNSResponse struct { // SML sensor readings SML struct { - TotalIn float64 `json:"total_in"` - TotalOut float64 `json:"total_out"` - PowerCurr int `json:"power_curr"` + TotalIn *float64 `json:"total_in"` + TotalOut *float64 `json:"total_out"` + PowerCurr *float64 `json:"power_curr"` + PowerL1 *float64 `json:"power_l1"` + PowerL2 *float64 `json:"power_l2"` + PowerL3 *float64 `json:"power_l3"` + VoltageL1 *float64 `json:"voltage_l1"` + VoltageL2 *float64 `json:"voltage_l2"` + VoltageL3 *float64 `json:"voltage_l3"` + CurrentL1 *float64 `json:"current_l1"` + CurrentL2 *float64 `json:"current_l2"` + CurrentL3 *float64 `json:"current_l3"` } } } @@ -95,7 +104,7 @@ type StatusSNSResponse struct { // Channels is a Tasmota specific helper type to handle meter value lists and single meter values type Channels []float64 -func (ch *Channels) Channel(channel int) (float64, error) { +func (ch *Channels) Value(channel int) (float64, error) { if *ch == nil { return 0, nil } diff --git a/meter/tasmota/types_test.go b/meter/tasmota/types_test.go index d1eb33639..a8126f89d 100644 --- a/meter/tasmota/types_test.go +++ b/meter/tasmota/types_test.go @@ -3,6 +3,9 @@ package tasmota import ( "encoding/json" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // Test StatusSNS response of all known Tasmota flavours @@ -11,28 +14,34 @@ func TestUnmarshalStatusSNSResponse(t *testing.T) { // Test cases for #6082 jsonstr := `{"StatusSNS":{"Time":"2023-02-05T20:31:48","ENERGY":{"TotalStartTime":"2023-02-05T11:04:13","Total":1290.3960,"Yesterday":0.8540,"Today":0.1730,"Power":47.11,"ApparentPower":0.0,"ReactivePower":0.0,"Factor":0.00,"Voltage":0.00,"Current":0.000}}}` - if err := json.Unmarshal([]byte(jsonstr), &res); err != nil { - t.Error(err) - } - if power, err := res.StatusSNS.Energy.Power.Channel(1); err == nil && power != 47.11 { - t.Error("StatusSNS.Energy.Power.Channel(1) != 47.11") + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + if power, err := res.StatusSNS.Energy.Power.Value(1); err == nil && power != 47.11 { + t.Error("StatusSNS.Energy.Power.Value(1) != 47.11") } // Test case for #5731 jsonstr = `{"StatusSNS":{"Time":"2023-01-09T18:57:39","Switch1":"ON","Switch2":"OFF","ANALOG":{"Temperature":49.6},"ENERGY":{"TotalStartTime":"2023-01-09T13:59:15","Total":0.077,"Yesterday":0.000,"Today":0.077,"Power":[8.15,0],"ApparentPower":[5,0],"ReactivePower":[4,0],"Factor":[0.00,0.00],"Frequency":50,"Voltage":237,"Current":[0.000,0.000]},"TempUnit":"C"}}` - if err := json.Unmarshal([]byte(jsonstr), &res); err != nil { - t.Error(err) - } - if power, err := res.StatusSNS.Energy.Power.Channel(1); err == nil && power != 8.15 { - t.Error("StatusSNS.Energy.Power.Channel(1) != 8.15") + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + if power, err := res.StatusSNS.Energy.Power.Value(1); err == nil && power != 8.15 { + t.Error("StatusSNS.Energy.Power.Value(1) != 8.15") } // Test case for #3787 jsonstr = `{"StatusSNS":{"Time":"2022-07-07T13:01:11","HTU21":{"Temperature":25.2,"Humidity":45.5},"SML":{"Total_in":34507.4761,"Total_out":14737.1422,"Power_curr":-894,"Meter_number":"0901454d48000000000"},"Gas":{"Count":1.84},"TempUnit":"C"}}` - if err := json.Unmarshal([]byte(jsonstr), &res); err != nil { - t.Error(err) - } - if res.StatusSNS.SML.PowerCurr != -894 { - t.Error("res.StatusSNS.SML.PowerCurr != -894") - } + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + assert.Equal(t, float64(-894), *res.StatusSNS.SML.PowerCurr) + + // Test case for #26857 + jsonstr = `{"StatusSNS":{"Time":"2026-01-21T11:07:31","SML":{"Total_in":2687.3687,"Total_out":582.4569,"Power_curr":-36,"Meter_Id":"0a01454652240487bd2a","power_l1":60,"power_l2":-111,"power_l3":14,"voltage_l1":235.3,"voltage_l2":235.7,"voltage_l3":235.7,"current_l1":0.98,"current_l2":1.47,"current_l3":1.18,"phase_angle_L2_L1":238,"phase_angle_L3_L1":118,"phase_angle_L1":23.0,"phase_angle_L2":108.0,"phase_angle_L3":350.0,"Frequenz":49.9}}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + assert.Equal(t, float64(-36), *res.StatusSNS.SML.PowerCurr) + assert.Equal(t, float64(60), *res.StatusSNS.SML.PowerL1) + assert.Equal(t, float64(-111), *res.StatusSNS.SML.PowerL2) + assert.Equal(t, float64(14), *res.StatusSNS.SML.PowerL3) + assert.Equal(t, float64(235.3), *res.StatusSNS.SML.VoltageL1) + assert.Equal(t, float64(235.7), *res.StatusSNS.SML.VoltageL2) + assert.Equal(t, float64(235.7), *res.StatusSNS.SML.VoltageL3) + assert.Equal(t, float64(0.98), *res.StatusSNS.SML.CurrentL1) + assert.Equal(t, float64(1.47), *res.StatusSNS.SML.CurrentL2) + assert.Equal(t, float64(1.18), *res.StatusSNS.SML.CurrentL3) } diff --git a/meter/tasmota_decorators.go b/meter/tasmota_decorators.go index 466fb6d14..a8fca5359 100644 --- a/meter/tasmota_decorators.go +++ b/meter/tasmota_decorators.go @@ -6,7 +6,7 @@ import ( "github.com/evcc-io/evcc/api" ) -func decorateTasmota(base *Tasmota, phaseVoltages func() (float64, float64, float64, error), phaseCurrents func() (float64, float64, float64, error)) api.Meter { +func decorateTasmota(base *Tasmota, phaseVoltages func() (float64, float64, float64, error), phaseCurrents func() (float64, float64, float64, error), phasePowers func() (float64, float64, float64, error)) api.Meter { switch { case phaseCurrents == nil && phaseVoltages == nil: return base @@ -22,7 +22,7 @@ func decorateTasmota(base *Tasmota, phaseVoltages func() (float64, float64, floa }, } - case phaseCurrents != nil && phaseVoltages == nil: + case phaseCurrents != nil && phasePowers == nil && phaseVoltages == nil: return &struct { *Tasmota api.PhaseCurrents @@ -33,7 +33,7 @@ func decorateTasmota(base *Tasmota, phaseVoltages func() (float64, float64, floa }, } - case phaseCurrents != nil && phaseVoltages != nil: + case phaseCurrents != nil && phasePowers == nil && phaseVoltages != nil: return &struct { *Tasmota api.PhaseCurrents @@ -47,6 +47,40 @@ func decorateTasmota(base *Tasmota, phaseVoltages func() (float64, float64, floa phaseVoltages: phaseVoltages, }, } + + case phaseCurrents != nil && phasePowers != nil && phaseVoltages == nil: + return &struct { + *Tasmota + api.PhaseCurrents + api.PhasePowers + }{ + Tasmota: base, + PhaseCurrents: &decorateTasmotaPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhasePowers: &decorateTasmotaPhasePowersImpl{ + phasePowers: phasePowers, + }, + } + + case phaseCurrents != nil && phasePowers != nil && phaseVoltages != nil: + return &struct { + *Tasmota + api.PhaseCurrents + api.PhasePowers + api.PhaseVoltages + }{ + Tasmota: base, + PhaseCurrents: &decorateTasmotaPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhasePowers: &decorateTasmotaPhasePowersImpl{ + phasePowers: phasePowers, + }, + PhaseVoltages: &decorateTasmotaPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } } return nil @@ -60,6 +94,14 @@ func (impl *decorateTasmotaPhaseCurrentsImpl) Currents() (float64, float64, floa return impl.phaseCurrents() } +type decorateTasmotaPhasePowersImpl struct { + phasePowers func() (float64, float64, float64, error) +} + +func (impl *decorateTasmotaPhasePowersImpl) Powers() (float64, float64, float64, error) { + return impl.phasePowers() +} + type decorateTasmotaPhaseVoltagesImpl struct { phaseVoltages func() (float64, float64, float64, error) } diff --git a/templates/definition/meter/tasmota-sml.yaml b/templates/definition/meter/tasmota-sml.yaml index 91ec6dd46..f2d2f9dae 100644 --- a/templates/definition/meter/tasmota-sml.yaml +++ b/templates/definition/meter/tasmota-sml.yaml @@ -12,6 +12,10 @@ requirements: - **Total_in** für den Gesamtverbrauch in KWh (4 Nachkommastellen) - **Total_out** für den Gesamteinspeisung in KWh mit (4 Nachkommastellen) - **Power_curr** für den aktuellen Verbrauch bzw. die aktuelle Einspeisung (0 Nachkommastellen) + Optional werden auch Phasenwerte unterstützt. + - **power_l1**, **power_l2**, **power_l3** für die Leistung der einzelnen Phasen in W (0 Nachkommastellen) + - **voltage_l1**, **voltage_l2**, **voltage_l3** für die Spannung der einzelnen Phasen in V (4 Nachkommastellen) + - **current_l1**, **current_l2**, **current_l3** für die Stromstärke der einzelnen Phasen in A (4 Nachkommastellen) Ein entsprechendes Lesekopf-Script sieht wie folgt aus: ``` @@ -19,15 +23,21 @@ requirements: >B =>sensor53 r >M 1 - // highlight-next-line +1,3,s,16,9600,SML - // highlight-next-line 1,77070100010800ff@1000,Gesamtverbrauch,KWh,Total_in,4 - // highlight-next-line 1,77070100020800ff@1000,Gesamteinspeisung,KWh,Total_out,4 - // highlight-next-line 1,77070100100700ff@1,Verbrauch,W,Power_curr,0 1,77070100600100ff@#,Zählernummer,,Meter_Id,0 + # Optional + 1,77070100240700ff@1,Leistung_L1,W,power_l1,0 + 1,77070100380700ff@1,Leistung_L2,W,power_l2,0 + 1,770701004c0700ff@1,Leistung_L3,W,power_l3,0 + 1,77070100200700ff@1,Spannung L1,V,voltage_l1,4 + 1,77070100340700ff@1,Spannung L2,V,voltage_l2,4 + 1,77070100480700ff@1,Spannung L3,V,voltage_l3,4 + 1,770701001f0700ff@1,Strom L1,A,current_l1,4 + 1,77070100330700ff@1,Strom L2,A,current_l2,4 + 1,77070100470700ff@1,Strom L3,A,current_l3,4 # ``` en: | @@ -36,6 +46,10 @@ requirements: - **Total_in** for the total consumption in KWh (4 decimal places) - **Total_out** for the total feed-in in KWh (4 decimal places) - **Power_curr** for the current consumption or the current feed-in in W (0 decimal places) + As an option, phase values are also supported. + - **power_l1**, **power_l2**, **power_l3** for the power of the individual phases in W (0 decimal places) + - **voltage_l1**, **voltage_l2**, **voltage_l3** for the voltage of the individual phases in V (4 decimal places) + - **current_l1**, **current_l2**, **current_l3** for the current of the individual phases in A (4 decimal places) A corresponding IR reader script looks like this: ``` @@ -43,15 +57,21 @@ requirements: >B =>sensor53 r >M 1 - // highlight-next-line +1,3,s,16,9600,SML - // highlight-next-line 1,77070100010800ff@1000,Gesamtverbrauch,KWh,Total_in,4 - // highlight-next-line 1,77070100020800ff@1000,Gesamteinspeisung,KWh,Total_out,4 - // highlight-next-line 1,77070100100700ff@1,Verbrauch,W,Power_curr,0 1,77070100600100ff@#,Zählernummer,,Meter_Id,0 + # Optional + 1,77070100240700ff@1,Leistung_L1,W,power_l1,0 + 1,77070100380700ff@1,Leistung_L2,W,power_l2,0 + 1,770701004c0700ff@1,Leistung_L3,W,power_l3,0 + 1,77070100200700ff@1,Spannung L1,V,voltage_l1,4 + 1,77070100340700ff@1,Spannung L2,V,voltage_l2,4 + 1,77070100480700ff@1,Spannung L3,V,voltage_l3,4 + 1,770701001f0700ff@1,Strom L1,A,current_l1,4 + 1,77070100330700ff@1,Strom L2,A,current_l2,4 + 1,77070100470700ff@1,Strom L3,A,current_l3,4 # ``` params: