Tasmota: add SML-IR reader phase readings (#26920)

This commit is contained in:
Markus Thierolf 2026-02-08 22:29:27 +01:00 • committed by GitHub
parent 7b8256f93a
commit 5a8607fbba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 210 additions and 67 deletions

View file

@ -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

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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: