From 8d8ac5c72e26f6c7341a7ee89a6852cff785eb95 Mon Sep 17 00:00:00 2001 From: Markus Thierolf <77847348+thierolm@users.noreply.github.com> Date: Tue, 31 Oct 2023 21:09:21 +0100 Subject: [PATCH] Tasmota: enable meter/relay channel-list (#10171) --- charger/tasmota.go | 30 ++- meter/tasmota.go | 31 ++- meter/tasmota/connection.go | 229 +++++++++++++-------- meter/tasmota_decorators.go | 69 +++++++ templates/definition/charger/tasmota.yaml | 12 +- templates/definition/meter/tasmota-3p.yaml | 24 +++ templates/definition/meter/tasmota.yaml | 14 +- templates/evcc.io/brands.json | 2 +- 8 files changed, 305 insertions(+), 106 deletions(-) create mode 100644 meter/tasmota_decorators.go create mode 100644 templates/definition/meter/tasmota-3p.yaml diff --git a/charger/tasmota.go b/charger/tasmota.go index a0f0a3179..5c0b13ede 100644 --- a/charger/tasmota.go +++ b/charger/tasmota.go @@ -15,8 +15,7 @@ import ( // Tasmota charger implementation type Tasmota struct { - conn *tasmota.Connection - channel int + conn *tasmota.Connection *switchSocket } @@ -32,10 +31,10 @@ func NewTasmotaFromConfig(other map[string]interface{}) (api.Charger, error) { User string Password string StandbyPower float64 - Channel int + Channel []int Cache time.Duration }{ - Channel: 1, + Channel: []int{1}, Cache: time.Second, } @@ -47,20 +46,19 @@ func NewTasmotaFromConfig(other map[string]interface{}) (api.Charger, error) { } // NewTasmota creates Tasmota charger -func NewTasmota(embed embed, uri, user, password string, channel int, standbypower float64, cache time.Duration) (*Tasmota, error) { - conn, err := tasmota.NewConnection(uri, user, password, channel, cache) +func NewTasmota(embed embed, uri, user, password string, channels []int, standbypower float64, cache time.Duration) (*Tasmota, error) { + conn, err := tasmota.NewConnection(uri, user, password, channels, cache) if err != nil { return nil, err } c := &Tasmota{ - conn: conn, - channel: channel, + conn: conn, } c.switchSocket = NewSwitchSocket(&embed, c.Enabled, c.conn.CurrentPower, standbypower) - return c, c.conn.ChannelExists(channel) + return c, c.conn.RelayExists() } // Enabled implements the api.Charger interface @@ -79,3 +77,17 @@ var _ api.MeterEnergy = (*Tasmota)(nil) func (c *Tasmota) TotalEnergy() (float64, error) { return c.conn.TotalEnergy() } + +var _ api.PhaseCurrents = (*Tasmota)(nil) + +// Currents implements the api.PhaseCurrents interface +func (c *Tasmota) Currents() (float64, float64, float64, error) { + return c.conn.Currents() +} + +var _ api.PhaseVoltages = (*Tasmota)(nil) + +// Voltages implements the api.PhaseVoltages interface +func (c *Tasmota) Voltages() (float64, float64, float64, error) { + return c.conn.Voltages() +} diff --git a/meter/tasmota.go b/meter/tasmota.go index e069bbd18..f046b8089 100644 --- a/meter/tasmota.go +++ b/meter/tasmota.go @@ -1,6 +1,7 @@ package meter import ( + "strings" "time" "github.com/evcc-io/evcc/api" @@ -19,17 +20,19 @@ func init() { registry.Add("tasmota", NewTasmotaFromConfig) } +//go:generate go run ../cmd/tools/decorate.go -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)" + // NewTasmotaFromConfig creates a Tasmota meter from generic config func NewTasmotaFromConfig(other map[string]interface{}) (api.Meter, error) { cc := struct { URI string User string Password string - Channel int + Channel []int Usage string Cache time.Duration }{ - Channel: 1, + Channel: []int{1}, Cache: time.Second, } @@ -37,12 +40,12 @@ func NewTasmotaFromConfig(other map[string]interface{}) (api.Meter, error) { return nil, err } - return NewTasmota(cc.URI, cc.User, cc.Password, cc.Usage, cc.Channel, cc.Cache) + return NewTasmota(cc.URI, cc.User, cc.Password, strings.ToLower(cc.Usage), cc.Channel, cc.Cache) } // NewTasmota creates Tasmota meter -func NewTasmota(uri, user, password, usage string, channel int, cache time.Duration) (*Tasmota, error) { - conn, err := tasmota.NewConnection(uri, user, password, channel, cache) +func NewTasmota(uri, user, password, usage string, channels []int, cache time.Duration) (api.Meter, error) { + conn, err := tasmota.NewConnection(uri, user, password, channels, cache) if err != nil { return nil, err } @@ -52,7 +55,13 @@ func NewTasmota(uri, user, password, usage string, channel int, cache time.Durat usage: usage, } - return c, err + var currents, voltages func() (float64, float64, float64, error) + if usage != "grid" { + currents = c.currents + voltages = c.voltages + } + + return decorateTasmota(c, currents, voltages), nil } var _ api.Meter = (*Tasmota)(nil) @@ -74,3 +83,13 @@ 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() +} + +// voltages implements the api.PhaseVoltages interface +func (c *Tasmota) voltages() (float64, float64, float64, error) { + return c.conn.Voltages() +} diff --git a/meter/tasmota/connection.go b/meter/tasmota/connection.go index 9803765d2..b23215e3b 100644 --- a/meter/tasmota/connection.go +++ b/meter/tasmota/connection.go @@ -17,24 +17,35 @@ import ( type Connection struct { *request.Helper uri, user, password string - channel int + channels []int statusSnsG provider.Cacheable[StatusSNSResponse] statusStsG provider.Cacheable[StatusSTSResponse] } // NewConnection creates a Tasmota connection -func NewConnection(uri, user, password string, channel int, cache time.Duration) (*Connection, error) { +func NewConnection(uri, user, password string, channels []int, cache time.Duration) (*Connection, error) { if uri == "" { return nil, errors.New("missing uri") } + used := make(map[int]bool) + for _, c := range channels { + if c < 1 || c > 8 { + return nil, fmt.Errorf("invalid channel: %d", c) + } + if used[c] { + return nil, fmt.Errorf("duplicate channel: %d", c) + } + used[c] = true + } + log := util.NewLogger("tasmota") c := &Connection{ Helper: request.NewHelper(log), uri: util.DefaultScheme(strings.TrimRight(uri, "/"), "http"), user: user, password: password, - channel: channel, + channels: channels, } c.Client.Transport = request.NewTripper(log, transport.Insecure()) @@ -65,34 +76,36 @@ func NewConnection(uri, user, password string, channel int, cache time.Duration) } // channelExists checks the existence of the configured relay channel interface -func (c *Connection) ChannelExists(channel int) error { +func (c *Connection) RelayExists() error { res, err := c.statusStsG.Get() if err != nil { return err } var ok bool - switch channel { - case 1: - ok = res.StatusSTS.Power != "" || res.StatusSTS.Power1 != "" - case 2: - ok = res.StatusSTS.Power2 != "" - case 3: - ok = res.StatusSTS.Power3 != "" - case 4: - ok = res.StatusSTS.Power4 != "" - case 5: - ok = res.StatusSTS.Power5 != "" - case 6: - ok = res.StatusSTS.Power6 != "" - case 7: - ok = res.StatusSTS.Power7 != "" - case 8: - ok = res.StatusSTS.Power8 != "" - } + for _, channel := range c.channels { + switch channel { + case 1: + ok = res.StatusSTS.Power != "" || res.StatusSTS.Power1 != "" + case 2: + ok = res.StatusSTS.Power2 != "" + case 3: + ok = res.StatusSTS.Power3 != "" + case 4: + ok = res.StatusSTS.Power4 != "" + case 5: + ok = res.StatusSTS.Power5 != "" + case 6: + ok = res.StatusSTS.Power6 != "" + case 7: + ok = res.StatusSTS.Power7 != "" + case 8: + ok = res.StatusSTS.Power8 != "" + } - if !ok { - return fmt.Errorf("invalid relay channel: %d", channel) + if !ok { + return fmt.Errorf("invalid relay channel: %d", channel) + } } return nil @@ -100,53 +113,56 @@ func (c *Connection) ChannelExists(channel int) error { // Enable implements the api.Charger interface func (c *Connection) Enable(enable bool) error { - cmd := fmt.Sprintf("Power%d off", c.channel) - if enable { - cmd = fmt.Sprintf("Power%d on", c.channel) - } + for _, channel := range c.channels { - parameters := url.Values{ - "user": []string{c.user}, - "password": []string{c.password}, - "cmnd": []string{cmd}, - } + cmd := fmt.Sprintf("Power%d off", channel) + if enable { + cmd = fmt.Sprintf("Power%d on", channel) + } - var res PowerResponse - if err := c.GetJSON(fmt.Sprintf("%s/cm?%s", c.uri, parameters.Encode()), &res); err != nil { - return err - } + parameters := url.Values{ + "user": []string{c.user}, + "password": []string{c.password}, + "cmnd": []string{cmd}, + } - var on bool - switch c.channel { - case 2: - on = strings.ToUpper(res.Power2) == "ON" - case 3: - on = strings.ToUpper(res.Power3) == "ON" - case 4: - on = strings.ToUpper(res.Power4) == "ON" - case 5: - on = strings.ToUpper(res.Power5) == "ON" - case 6: - on = strings.ToUpper(res.Power6) == "ON" - case 7: - on = strings.ToUpper(res.Power7) == "ON" - case 8: - on = strings.ToUpper(res.Power8) == "ON" - default: - on = strings.ToUpper(res.Power) == "ON" || strings.ToUpper(res.Power1) == "ON" + var res PowerResponse + if err := c.GetJSON(fmt.Sprintf("%s/cm?%s", c.uri, parameters.Encode()), &res); err != nil { + return err + } + + var enabled bool + switch channel { + case 2: + enabled = strings.ToUpper(res.Power2) == "ON" + case 3: + enabled = strings.ToUpper(res.Power3) == "ON" + case 4: + enabled = strings.ToUpper(res.Power4) == "ON" + case 5: + enabled = strings.ToUpper(res.Power5) == "ON" + case 6: + enabled = strings.ToUpper(res.Power6) == "ON" + case 7: + enabled = strings.ToUpper(res.Power7) == "ON" + case 8: + enabled = strings.ToUpper(res.Power8) == "ON" + default: + enabled = strings.ToUpper(res.Power) == "ON" || strings.ToUpper(res.Power1) == "ON" + } + + switch { + case enable && !enabled: + return errors.New("switchOn failed") + case !enable && enabled: + return errors.New("switchOff failed") + } } c.statusSnsG.Reset() c.statusStsG.Reset() - switch { - case enable && !on: - return errors.New("switchOn failed") - case !enable && on: - return errors.New("switchOff failed") - default: - return nil - } + return nil } // Enabled implements the api.Charger interface @@ -156,33 +172,46 @@ func (c *Connection) Enabled() (bool, error) { return false, err } - switch c.channel { - case 2: - return strings.ToUpper(res.StatusSTS.Power2) == "ON", err - case 3: - return strings.ToUpper(res.StatusSTS.Power3) == "ON", err - case 4: - return strings.ToUpper(res.StatusSTS.Power4) == "ON", err - case 5: - return strings.ToUpper(res.StatusSTS.Power5) == "ON", err - case 6: - return strings.ToUpper(res.StatusSTS.Power6) == "ON", err - case 7: - return strings.ToUpper(res.StatusSTS.Power7) == "ON", err - case 8: - return strings.ToUpper(res.StatusSTS.Power8) == "ON", err - default: - return strings.ToUpper(res.StatusSTS.Power) == "ON" || strings.ToUpper(res.StatusSTS.Power1) == "ON", err + var enabled bool + for _, channel := range c.channels { + switch channel { + case 2: + enabled = strings.ToUpper(res.StatusSTS.Power2) == "ON" + case 3: + enabled = strings.ToUpper(res.StatusSTS.Power3) == "ON" + case 4: + enabled = strings.ToUpper(res.StatusSTS.Power4) == "ON" + case 5: + enabled = strings.ToUpper(res.StatusSTS.Power5) == "ON" + case 6: + enabled = strings.ToUpper(res.StatusSTS.Power6) == "ON" + case 7: + enabled = strings.ToUpper(res.StatusSTS.Power7) == "ON" + case 8: + enabled = strings.ToUpper(res.StatusSTS.Power8) == "ON" + default: + enabled = strings.ToUpper(res.StatusSTS.Power) == "ON" || strings.ToUpper(res.StatusSTS.Power1) == "ON" + + } } + return enabled, nil } // CurrentPower implements the api.Meter interface func (c *Connection) CurrentPower() (float64, error) { - res, err := c.statusSnsG.Get() + s, err := c.statusSnsG.Get() if err != nil { return 0, err } - return res.StatusSNS.Energy.Power.Channel(c.channel) + var res float64 + for _, channel := range c.channels { + power, err := s.StatusSNS.Energy.Power.Channel(channel) + if err != nil { + return 0, err + } + res += power + } + return res, nil } // TotalEnergy implements the api.MeterEnergy interface @@ -191,6 +220,42 @@ func (c *Connection) TotalEnergy() (float64, error) { return res.StatusSNS.Energy.Total, err } +// 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 + } + + var res [3]float64 + for i, c := range c.channels { + res[i], err = s.StatusSNS.Energy.Current.Channel(c) + if err != nil { + return 0, 0, 0, err + } + } + + return res[0], res[1], res[2], err +} + +// 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 + } + + var res [3]float64 + for i, c := range c.channels { + res[i], err = s.StatusSNS.Energy.Voltage.Channel(c) + if err != nil { + return 0, 0, 0, err + } + } + + return res[0], res[1], res[2], err +} + // SmlPower provides the sml sensor power func (c *Connection) SmlPower() (float64, error) { res, err := c.statusSnsG.Get() diff --git a/meter/tasmota_decorators.go b/meter/tasmota_decorators.go new file mode 100644 index 000000000..466fb6d14 --- /dev/null +++ b/meter/tasmota_decorators.go @@ -0,0 +1,69 @@ +package meter + +// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT. + +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 { + switch { + case phaseCurrents == nil && phaseVoltages == nil: + return base + + case phaseCurrents == nil && phaseVoltages != nil: + return &struct { + *Tasmota + api.PhaseVoltages + }{ + Tasmota: base, + PhaseVoltages: &decorateTasmotaPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + + case phaseCurrents != nil && phaseVoltages == nil: + return &struct { + *Tasmota + api.PhaseCurrents + }{ + Tasmota: base, + PhaseCurrents: &decorateTasmotaPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + } + + case phaseCurrents != nil && phaseVoltages != nil: + return &struct { + *Tasmota + api.PhaseCurrents + api.PhaseVoltages + }{ + Tasmota: base, + PhaseCurrents: &decorateTasmotaPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhaseVoltages: &decorateTasmotaPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + } + + return nil +} + +type decorateTasmotaPhaseCurrentsImpl struct { + phaseCurrents func() (float64, float64, float64, error) +} + +func (impl *decorateTasmotaPhaseCurrentsImpl) Currents() (float64, float64, float64, error) { + return impl.phaseCurrents() +} + +type decorateTasmotaPhaseVoltagesImpl struct { + phaseVoltages func() (float64, float64, float64, error) +} + +func (impl *decorateTasmotaPhaseVoltagesImpl) Voltages() (float64, float64, float64, error) { + return impl.phaseVoltages() +} diff --git a/templates/definition/charger/tasmota.yaml b/templates/definition/charger/tasmota.yaml index 2ef4c5223..3400451e8 100644 --- a/templates/definition/charger/tasmota.yaml +++ b/templates/definition/charger/tasmota.yaml @@ -1,6 +1,6 @@ template: tasmota products: - - brand: Tasmota + - brand: Tasmota (1 Phase Charger) group: switchsockets params: - name: host @@ -16,11 +16,11 @@ params: default: 1 required: true description: - de: Nummer des Schaltkanals - en: Device relay channel number + de: Schaltkanal Nummer + en: Relaychannel number help: - de: Nummer des Schaltkanals (1-8), bei Geräten mit mehr als einem Schalter - en: Device relay channel number (allowed range 1-8) + de: Schaltkanal (1-8) + en: Relaychannel number (1-8) - preset: switchsocket render: | type: tasmota @@ -31,5 +31,5 @@ render: | {{- if .password }} password: {{ .password }} {{- end }} - channel: {{ .channel }} # relay channel (allowed range: 1-8) + channel: [{{ .channel }}] # list of relay channels [1,2,....,8] {{ include "switchsocket" . }} diff --git a/templates/definition/meter/tasmota-3p.yaml b/templates/definition/meter/tasmota-3p.yaml new file mode 100644 index 000000000..60a9786c3 --- /dev/null +++ b/templates/definition/meter/tasmota-3p.yaml @@ -0,0 +1,24 @@ +template: tasmota-3p +products: + - description: + generic: Tasmota (3 Phase Meter - meter channels 1,2+3 must be used) +group: switchsockets +params: + - name: usage + choice: ["grid", "pv", "battery", "charge"] + - name: host + - name: user + required: false + help: + de: Standard-User ist admin + en: admin is default + - name: password + required: false + mask: true +render: | + type: tasmota + uri: http://{{ .host }} + usage: {{ .usage }} + user: {{ .user }} + password: {{ .password }} + channel: [1,2,3] # list of meter channels [1,2,....,8] diff --git a/templates/definition/meter/tasmota.yaml b/templates/definition/meter/tasmota.yaml index 65d888b1d..20274ad70 100644 --- a/templates/definition/meter/tasmota.yaml +++ b/templates/definition/meter/tasmota.yaml @@ -1,11 +1,11 @@ template: tasmota products: - description: - generic: Tasmota + generic: Tasmota (1 Phase Meter) group: switchsockets params: - name: usage - choice: ["grid", "pv", "battery"] + choice: ["grid", "pv", "battery", "charge"] - name: host - name: user required: false @@ -15,9 +15,19 @@ params: - name: password required: false mask: true + - name: channel + default: 1 + required: true + description: + de: Kanal Nummer + en: Channel number + help: + de: Meterkanal (1-8) + en: Meterchannel number (1-8) render: | type: tasmota uri: http://{{ .host }} usage: {{ .usage }} user: {{ .user }} password: {{ .password }} + channel: [{{ .channel }}] # list of meter channels [1,2,....,8] diff --git a/templates/evcc.io/brands.json b/templates/evcc.io/brands.json index e5773e834..71e742289 100644 --- a/templates/evcc.io/brands.json +++ b/templates/evcc.io/brands.json @@ -78,7 +78,7 @@ "HomeWizard", "myStrom", "Shelly", - "Tasmota", + "Tasmota (1 Phase Charger)", "TP-Link" ], "Meters": [