diff --git a/meter/shelly/connection.go b/meter/shelly/connection.go index 388b2d7ad..4024cb86f 100644 --- a/meter/shelly/connection.go +++ b/meter/shelly/connection.go @@ -15,12 +15,11 @@ import ( // Connection is the Shelly connection type Connection struct { *request.Helper - uri string - channel int - gen int // Shelly api generation - devicetype string // Shelly device type - app string // Shelly device app code - profile string // Shelly device profile + uri string + channel int + gen int // Shelly api generation + model string // Shelly device type + profile string // Shelly device profile } // NewConnection creates a new Shelly device connection. @@ -42,39 +41,41 @@ func NewConnection(uri, user, password string, channel int) (*Connection, error) return nil, err } + if (resp.Auth || resp.AuthEn) && (user == "" || password == "") { + return nil, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac) + } + conn := &Connection{ - Helper: client, - channel: channel, - gen: resp.Gen, - devicetype: resp.Type, - app: resp.App, - profile: resp.Profile, + Helper: client, + uri: util.DefaultScheme(uri, "http"), + channel: channel, + gen: resp.Gen, + model: strings.Split(resp.Type+resp.Model, "-")[0], + profile: resp.Profile, } conn.Client.Transport = request.NewTripper(log, transport.Insecure()) - if (resp.Auth || resp.AuthEn) && (user == "" || password == "") { - return conn, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac) + // Set default profile to "monophase" if not provided + if resp.Profile == "" { + resp.Profile = "monophase" } switch conn.gen { case 0, 1: // Shelly GEN 1 API // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview - conn.uri = util.DefaultScheme(uri, "http") if user != "" { log.Redact(transport.BasicAuthHeader(user, password)) conn.Client.Transport = transport.BasicAuth(user, password, conn.Client.Transport) } - case 2, 3: // Shelly GEN 2+ API // https://shelly-api-docs.shelly.cloud/gen2/ - conn.uri = fmt.Sprintf("%s/rpc", util.DefaultScheme(uri, "http")) + conn.uri += "/rpc" if user != "" { conn.Client.Transport = digest.NewTransport(user, password, conn.Client.Transport) } - default: return conn, fmt.Errorf("%s (%s) unknown api generation (%d)", resp.Type, resp.Model, conn.gen) } diff --git a/meter/shelly/energymeter.go b/meter/shelly/energymeter.go index 47a70ac72..6de49cb2a 100644 --- a/meter/shelly/energymeter.go +++ b/meter/shelly/energymeter.go @@ -3,12 +3,12 @@ package shelly import "github.com/evcc-io/evcc/api" type EnergyMeter struct { - *Connection + conn *Connection } func NewEnergyMeter(conn *Connection) *EnergyMeter { res := &EnergyMeter{ - Connection: conn, + conn: conn, } return res @@ -17,7 +17,7 @@ func NewEnergyMeter(conn *Connection) *EnergyMeter { // CurrentPower implements the api.Meter interface func (sh *EnergyMeter) CurrentPower() (float64, error) { var res Gen2StatusResponse - if err := sh.Connection.execGen2Cmd("EM.GetStatus", false, &res); err != nil { + if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { return 0, err } @@ -27,7 +27,7 @@ func (sh *EnergyMeter) CurrentPower() (float64, error) { // TotalEnergy implements the api.Meter interface func (sh *EnergyMeter) TotalEnergy() (float64, error) { var res Gen2EmDataStatusResponse - if err := sh.Connection.execGen2Cmd("EMData.GetStatus", false, &res); err != nil { + if err := sh.conn.execGen2Cmd("EMData.GetStatus", false, &res); err != nil { return 0, err } @@ -39,7 +39,7 @@ var _ api.PhaseCurrents = (*EnergyMeter)(nil) // Currents implements the api.PhaseCurrents interface func (sh *EnergyMeter) Currents() (float64, float64, float64, error) { var res Gen2StatusResponse - if err := sh.Connection.execGen2Cmd("EM.GetStatus", false, &res); err != nil { + if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { return 0, 0, 0, err } @@ -51,7 +51,7 @@ var _ api.PhaseVoltages = (*EnergyMeter)(nil) // Voltages implements the api.PhaseVoltages interface func (sh *EnergyMeter) Voltages() (float64, float64, float64, error) { var res Gen2StatusResponse - if err := sh.Connection.execGen2Cmd("EM.GetStatus", false, &res); err != nil { + if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { return 0, 0, 0, err } @@ -63,7 +63,7 @@ var _ api.PhasePowers = (*EnergyMeter)(nil) // Powers implements the api.PhasePowers interface func (sh *EnergyMeter) Powers() (float64, float64, float64, error) { var res Gen2StatusResponse - if err := sh.Connection.execGen2Cmd("EM.GetStatus", false, &res); err != nil { + if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { return 0, 0, 0, err } diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go new file mode 100644 index 000000000..73b328326 --- /dev/null +++ b/meter/shelly/gen1.go @@ -0,0 +1,25 @@ +package shelly + +// Gen1API endpoint reference: https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview + +type Gen1SwitchResponse struct { + Ison bool +} + +type Gen1Status struct { + Meters []struct { + Power float64 + Current float64 + Voltage float64 + Total float64 + Total_Returned float64 + } + // Shelly EM meter JSON response + EMeters []struct { + Power float64 + Current float64 + Voltage float64 + Total float64 + Total_Returned float64 + } +} diff --git a/meter/shelly/gen1_test.go b/meter/shelly/gen1_test.go new file mode 100644 index 000000000..ac1f50fa4 --- /dev/null +++ b/meter/shelly/gen1_test.go @@ -0,0 +1,45 @@ +package shelly + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test Gen1Status response +func TestUnmarshalGen1Status(t *testing.T) { + { + // Shelly 1 PM channel 0 (1) + var res Gen1Status + + jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-54},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"17:59","unixtime":1676134770,"serial":2437,"has_update":true,"mac":"84CCA8XXXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"source":"http"}],"meters":[{"power":4711.12,"overpower":0.00,"is_valid":true,"timestamp":1676138370,"counters":[0.000, 0.000, 0.000],"total":6472513}],"inputs":[{"input":0,"event":"","event_cnt":0}],"temperature":16.79,"overtemperature":false,"tmp":{"tC":16.79,"tF":62.22, "is_valid":true},"temperature_status":"Normal","ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221108-153925/v1.12.1-1PM-fix-g2821131","old_version":"20220209-094317/v1.11.8-g8c7bb8d"},"ram_total":50456,"ram_free":37056,"fs_size":233681,"fs_free":149094,"uptime":17284290}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + + assert.Equal(t, 107875.21666666666, gen1Energy("SHSW-PM", res.Meters[0].Total)) + assert.Equal(t, 4711.12, res.Meters[0].Power) + } + + { + // Shelly 1 channel 0 (1) + var res Gen1Status + + jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-57},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"19:25","unixtime":1676139913,"serial":959,"has_update":true,"mac":"E8DB8XXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"source":"timer"}],"meters":[{"power":81.5,"is_valid":true}],"inputs":[{"input":0,"event":"","event_cnt":0}],"ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221027-091427/v1.12.1-ga9117d3","old_version":"20211109-124958/v1.11.7-g682a0db"},"ram_total":50880,"ram_free":38796,"fs_size":233681,"fs_free":151102,"uptime":20319391}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + + assert.Equal(t, 0.0, gen1Energy("SHSW-1", res.Meters[0].Total)) + assert.Equal(t, 81.5, res.Meters[0].Power) + } + + { + // Shelly EM channel 0 (1) + var res Gen1Status + + jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX","ip":"192.168.178.XXX","rssi":-55},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"11:16","unixtime":1676110566,"serial":21580,"has_update":false,"mac":"C45BXXXXX","cfg_changed_cnt":0,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"is_valid":true,"source":"input"}],"emeters":[{"power":-620.34,"reactive":714.48,"pf":-0.66,"voltage":235.68,"is_valid":true,"total":401472.9,"total_returned":653673.7},{"power":0.00,"reactive":0.00,"pf":0.00,"voltage":235.68,"is_valid":true,"total":173411.3,"total_returned":294.2}],"update":{"status":"idle","has_update":false,"new_version":"20221027-105518/v1.12.1-ga9117d3","old_version":"20221027-105518/v1.12.1-ga9117d3"},"ram_total":51072,"ram_free":35660,"fs_size":233681,"fs_free":156373,"uptime":2226140}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + + assert.Equal(t, 401472.9, gen1Energy("SHEM", res.EMeters[0].Total)) + assert.Equal(t, -620.34, res.EMeters[0].Power) + } +} diff --git a/meter/shelly/gen2.go b/meter/shelly/gen2.go new file mode 100644 index 000000000..639105c4a --- /dev/null +++ b/meter/shelly/gen2.go @@ -0,0 +1,82 @@ +package shelly + +// Gen2API endpoint reference: https://shelly-api-docs.shelly.cloud/gen2/ + +type Gen2RpcPost struct { + Id int `json:"id"` + On bool `json:"on"` + Src string `json:"src"` + Method string `json:"method"` +} + +type Gen2SwitchStatus struct { + Output bool + Apower float64 + Voltage float64 + Current float64 + Aenergy struct { + Total float64 + } + Ret_Aenergy struct { + Total float64 + } +} + +type Gen2EMStatus struct { + TotalActPower float64 `json:"total_act_power"` + ACurrent float64 `json:"a_current"` + BCurrent float64 `json:"b_current"` + CCurrent float64 `json:"c_current"` + AVoltage float64 `json:"a_voltage"` + BVoltage float64 `json:"b_voltage"` + CVoltage float64 `json:"c_voltage"` + AActPower float64 `json:"a_act_power"` + BActPower float64 `json:"b_act_power"` + CActPower float64 `json:"c_act_power"` +} + +type Gen2EMData struct { + TotalAct float64 `json:"total_act"` + TotalActRet float64 `json:"total_act_ret"` +} + +type Gen2EM1Status struct { + Current float64 `json:"current"` + Voltage float64 `json:"voltage"` + ActPower float64 `json:"act_power"` +} + +type Gen2EM1Data struct { + TotalActEnergy float64 `json:"total_act_energy"` + TotalActRetEnergy float64 `json:"total_act_ret_energy"` +} + +type Gen2StatusResponse struct { + Switch0 Gen2SwitchStatus `json:"switch:0"` + Switch1 Gen2SwitchStatus `json:"switch:1"` + Switch2 Gen2SwitchStatus `json:"switch:2"` + Pm0 Gen2SwitchStatus `json:"pm1:0"` + Pm1 Gen2SwitchStatus `json:"pm2:1"` + Pm2 Gen2SwitchStatus `json:"pm3:2"` + // additional shelly Pro EM meter JSON response + TotalPower float64 `json:"total_act_power"` + CurrentA float64 `json:"a_current"` + CurrentB float64 `json:"b_current"` + CurrentC float64 `json:"c_current"` + VoltageA float64 `json:"a_voltage"` + VoltageB float64 `json:"b_voltage"` + VoltageC float64 `json:"c_voltage"` + PowerA float64 `json:"a_act_power"` + PowerB float64 `json:"b_act_power"` + PowerC float64 `json:"c_act_power"` + Em0 Gen2EM1Status `json:"em1:0"` + Em1 Gen2EM1Status `json:"em1:1"` + Em2 Gen2EM1Status `json:"em1:2"` + Em0Data Gen2EM1Data `json:"em1data:0"` + Em1Data Gen2EM1Data `json:"em1data:1"` + Em2Data Gen2EM1Data `json:"em1data:2"` +} + +type Gen2EmDataStatusResponse struct { + TotalEnergy float64 `json:"total_act"` +} diff --git a/meter/shelly/gen2_test.go b/meter/shelly/gen2_test.go new file mode 100644 index 000000000..efeb1de8c --- /dev/null +++ b/meter/shelly/gen2_test.go @@ -0,0 +1,80 @@ +package shelly + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test Gen2+ status responses +func TestUnmarshalGen2StatusResponse(t *testing.T) { + { + // Shelly Pro 1PM channel 0 (1) + var res Gen2StatusResponse + + jsonstr := `{"ble":{},"cloud":{"connected":true},"eth":{"ip":null},"input:0":{"id":0,"state":false},"input:1":{"id":1,"state":false},"mqtt":{"connected":false},"switch:0":{"id":0, "source":"HTTP", "output":false, "apower":47.11, "voltage":232.0, "current":0.000, "pf":0.00, "aenergy":{"total":5.125,"by_minute":[0.000,0.000,0.000],"minute_ts":1675718520},"temperature":{"tC":25.3, "tF":77.5}},"sys":{"mac":"30C6F78BB4D8","restart_required":false,"time":"22:22","unixtime":1675718522,"uptime":45070,"ram_size":234204,"ram_free":137716,"fs_size":524288,"fs_free":172032,"cfg_rev":13,"kvs_rev":1,"schedule_rev":0,"webhook_rev":0,"available_updates":{"beta":{"version":"0.13.0-beta3"}}},"wifi":{"sta_ip":"192.168.178.64","status":"got ip","ssid":"***","rssi":-62},"ws":{"connected":false}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + + assert.Equal(t, 5.125, res.Switch0.Aenergy.Total) + assert.Equal(t, 47.11, res.Switch0.Apower) + } + + { + // Shelly PM Mini Gen3 channel 0 (1) + var res Gen2StatusResponse + + jsonstr := `{"ble":{},"cloud":{"connected":true},"mqtt":{"connected":false},"pm1:0":{"id":0, "voltage":239.9, "current":7.434, "apower":1780.1 ,"freq":50.1,"aenergy":{"total":3551.682,"by_minute":[15234.772,29611.247,29825.821],"minute_ts":1719917850},"ret_aenergy":{"total":0.000,"by_minute":[0.000,0.000,0.000],"minute_ts":1719917850}},"sys":{"mac":"84FCE638D818","restart_required":false,"time":"12:57","unixtime":1719917851,"uptime":62328,"ram_size":261744,"ram_free":151436,"fs_size":1048576,"fs_free":712704,"cfg_rev":10,"kvs_rev":1,"schedule_rev":0,"webhook_rev":0,"available_updates":{"stable":{"version":"1.3.3"}},"reset_reason":1},"wifi":{"sta_ip":"192.168.178.89","status":"got ip","ssid":"FritzBox 8 2.4","rssi":-62},"ws":{"connected":false}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + + assert.Equal(t, 3551.682, res.Pm0.Aenergy.Total) + assert.Equal(t, 1780.1, res.Pm0.Apower) + } + + { + // Shelly Pro 3EM + var res Gen2StatusResponse + + jsonstr := `{"ble":{},"bthome":{"errors":["bluetooth_disabled"]},"cloud":{"connected":true},"em1:0":{"id":0,"current":3.705,"voltage":242.8,"act_power":598.9,"aprt_power":900.6,"pf":0.66,"freq":50.0,"calibration":"factory"},"em1:1":{"id":1,"current":0.194,"voltage":242.8,"act_power":0.0,"aprt_power":47.2,"pf":0.00,"freq":50.0,"calibration":"factory"},"em1:2":{"id":2,"current":0.027,"voltage":242.8,"act_power":0.0,"aprt_power":6.6,"pf":0.00,"freq":50.0,"calibration":"factory"},"em1data:0":{"id":0,"total_act_energy":3458.24,"total_act_ret_energy":1605.24},"em1data:1":{"id":1,"total_act_energy":2768.67,"total_act_ret_energy":25.49},"em1data:2":{"id":2,"total_act_energy":3.09,"total_act_ret_energy":0.71},"eth":{"ip":null},"modbus":{},"mqtt":{"connected":false},"sys":{"mac":"FCE8C0DBA850","restart_required":false,"time":"19:46","unixtime":1731404780,"uptime":563,"ram_size":247148,"ram_free":110596,"fs_size":524288,"fs_free":176128,"cfg_rev":21,"kvs_rev":0,"schedule_rev":3,"webhook_rev":1,"available_updates":{},"reset_reason":3},"temperature:0":{"id": 0,"tC":39.0, "tF":102.2},"wifi":{"sta_ip":"192.168.40.174","status":"got ip","ssid":"IoT","rssi":-67},"ws":{"connected":false}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + // Channel 0 (1) + assert.Equal(t, 598.9, res.Em0.ActPower) + assert.Equal(t, 3.705, res.Em0.Current) + assert.Equal(t, 242.8, res.Em0.Voltage) + assert.Equal(t, 3458.24, res.Em0Data.TotalActEnergy) + assert.Equal(t, 1605.24, res.Em0Data.TotalActRetEnergy) + // Channel 1 (2) + assert.Equal(t, 0.0, res.Em1.ActPower) + assert.Equal(t, 0.194, res.Em1.Current) + assert.Equal(t, 242.8, res.Em1.Voltage) + assert.Equal(t, 2768.67, res.Em1Data.TotalActEnergy) + assert.Equal(t, 25.49, res.Em1Data.TotalActRetEnergy) + // Channel 2 (3) + assert.Equal(t, 0.0, res.Em2.ActPower) + assert.Equal(t, 0.027, res.Em2.Current) + assert.Equal(t, 242.8, res.Em2.Voltage) + assert.Equal(t, 3.09, res.Em2Data.TotalActEnergy) + assert.Equal(t, 0.71, res.Em2Data.TotalActRetEnergy) + } + + { + // Shelly Pro EM-50 channel 0 + 1 + var res Gen2StatusResponse + + jsonstr := `{"ble":{},"bthome":{"errors":["bluetooth_disabled"]},"cloud":{"connected":true},"em1:0":{"id":0,"current":1.473,"voltage":226.9,"act_power":-332.2,"aprt_power":335.0,"pf":0.99,"freq":50.0,"calibration":"factory"},"em1:1":{"id":1,"current":0.428,"voltage":227.0,"act_power":-38.5,"aprt_power":97.4,"pf":0.38,"freq":50.0,"calibration":"factory"},"em1data:0":{"id":0,"total_act_energy":1264.15,"total_act_ret_energy":144792.28},"em1data:1":{"id":1,"total_act_energy":48002.83,"total_act_ret_energy":33241.59},"eth":{"ip":null},"modbus":{},"mqtt":{"connected":false},"switch:0":{"id":0, "source":"HTTP_in", "output":false,"temperature":{"tC":46.4, "tF":115.5}},"sys":{"mac":"08F9E0E8AF2C","restart_required":false,"time":"10:42","unixtime":1742809323,"uptime":3671372,"ram_size":249680,"ram_free":107492,"fs_size":524288,"fs_free":188416,"cfg_rev":12,"kvs_rev":0,"schedule_rev":1,"webhook_rev":0,"available_updates":{"beta":{"version":"1.5.1-beta2"}},"reset_reason":3},"wifi":{"sta_ip":"192.168.1.120","status":"got ip","ssid":"Spaetzlewerk","rssi":-61},"ws":{"connected":false}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + // Channel 0 (1) + assert.Equal(t, -332.2, res.Em0.ActPower) + assert.Equal(t, 1.473, res.Em0.Current) + assert.Equal(t, 226.9, res.Em0.Voltage) + assert.Equal(t, 1264.15, res.Em0Data.TotalActEnergy) + assert.Equal(t, 144792.28, res.Em0Data.TotalActRetEnergy) + // Channel 1 (2) + assert.Equal(t, -38.5, res.Em1.ActPower) + assert.Equal(t, 0.428, res.Em1.Current) + assert.Equal(t, 227.0, res.Em1.Voltage) + assert.Equal(t, 48002.83, res.Em1Data.TotalActEnergy) + assert.Equal(t, 33241.59, res.Em1Data.TotalActRetEnergy) + } +} diff --git a/meter/shelly/switch.go b/meter/shelly/switch.go index 59cda1a56..466c7eec6 100644 --- a/meter/shelly/switch.go +++ b/meter/shelly/switch.go @@ -8,12 +8,12 @@ import ( ) type Switch struct { - *Connection + conn *Connection } func NewSwitch(conn *Connection) *Switch { res := &Switch{ - Connection: conn, + conn: conn, } return res @@ -23,31 +23,30 @@ func NewSwitch(conn *Connection) *Switch { func (sh *Switch) CurrentPower() (float64, error) { var power float64 - d := sh.Connection - switch d.gen { + switch sh.conn.gen { case 0, 1: - var res Gen1StatusResponse - uri := fmt.Sprintf("%s/status", d.uri) - if err := d.GetJSON(uri, &res); err != nil { + var res Gen1Status + uri := fmt.Sprintf("%s/status", sh.conn.uri) + if err := sh.conn.GetJSON(uri, &res); err != nil { return 0, err } switch { - case d.channel < len(res.Meters): - power = res.Meters[d.channel].Power - case d.channel < len(res.EMeters): - power = res.EMeters[d.channel].Power + case sh.conn.channel < len(res.Meters): + power = res.Meters[sh.conn.channel].Power + case sh.conn.channel < len(res.EMeters): + power = res.EMeters[sh.conn.channel].Power default: return 0, errors.New("invalid channel, missing power meter") } default: var res Gen2StatusResponse - if err := d.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil { + if err := sh.conn.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil { return 0, err } - switch d.channel { + switch sh.conn.channel { case 1: power = res.Switch1.Apower + res.Pm1.Apower + res.Em1.ActPower case 2: @@ -63,17 +62,16 @@ func (sh *Switch) CurrentPower() (float64, error) { // Enabled implements the api.Charger interface func (sh *Switch) Enabled() (bool, error) { - d := sh.Connection - switch d.gen { + switch sh.conn.gen { case 0, 1: var res Gen1SwitchResponse - uri := fmt.Sprintf("%s/relay/%d", d.uri, d.channel) - err := d.GetJSON(uri, &res) + uri := fmt.Sprintf("%s/relay/%d", sh.conn.uri, sh.conn.channel) + err := sh.conn.GetJSON(uri, &res) return res.Ison, err default: - var res Gen2SwitchResponse - err := d.execGen2Cmd("Switch.GetStatus", false, &res) + var res Gen2SwitchStatus + err := sh.conn.execGen2Cmd("Switch.GetStatus", false, &res) return res.Output, err } } @@ -83,16 +81,15 @@ func (sh *Switch) Enable(enable bool) error { var err error onoff := map[bool]string{true: "on", false: "off"} - d := sh.Connection - switch d.gen { + switch sh.conn.gen { case 0, 1: var res Gen1SwitchResponse - uri := fmt.Sprintf("%s/relay/%d?turn=%s", d.uri, d.channel, onoff[enable]) - err = d.GetJSON(uri, &res) + uri := fmt.Sprintf("%s/relay/%d?turn=%s", sh.conn.uri, sh.conn.channel, onoff[enable]) + err = sh.conn.GetJSON(uri, &res) default: - var res Gen2SwitchResponse - err = d.execGen2Cmd("Switch.Set", enable, &res) + var res Gen2SwitchStatus + err = sh.conn.execGen2Cmd("Switch.Set", enable, &res) } return err @@ -102,33 +99,32 @@ func (sh *Switch) Enable(enable bool) error { func (sh *Switch) TotalEnergy() (float64, error) { var energy float64 - d := sh.Connection - switch d.gen { + switch sh.conn.gen { case 0, 1: - var res Gen1StatusResponse - uri := fmt.Sprintf("%s/status", d.uri) - if err := d.GetJSON(uri, &res); err != nil { + var res Gen1Status + uri := fmt.Sprintf("%s/status", sh.conn.uri) + if err := sh.conn.GetJSON(uri, &res); err != nil { return 0, err } switch { - case d.channel < len(res.Meters): - energy = res.Meters[d.channel].Total - case d.channel < len(res.EMeters): - energy = res.EMeters[d.channel].Total + case sh.conn.channel < len(res.Meters): + energy = res.Meters[sh.conn.channel].Total + case sh.conn.channel < len(res.EMeters): + energy = res.EMeters[sh.conn.channel].Total default: return 0, errors.New("invalid channel, missing power meter") } - energy = gen1Energy(d.devicetype, energy) + energy = gen1Energy(sh.conn.model, energy) default: var res Gen2StatusResponse - if err := d.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil { + if err := sh.conn.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil { return 0, err } - switch d.channel { + switch sh.conn.channel { case 1: energy = res.Switch1.Aenergy.Total + res.Pm1.Aenergy.Total + res.Em1Data.TotalActEnergy - res.Em1Data.TotalActRetEnergy case 2: diff --git a/meter/shelly/types.go b/meter/shelly/types.go index 52d3d4701..d177a1fa4 100644 --- a/meter/shelly/types.go +++ b/meter/shelly/types.go @@ -1,91 +1,15 @@ package shelly -// Shelly api homepage -// https://shelly-api-docs.shelly.cloud/#common-http-api +// DeviceInfo is the common /shelly endpoint response +// https://shelly-api-docs.shelly.cloud/gen1/#shelly +// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#http-endpoint-shelly type DeviceInfo struct { + Mac string `json:"mac"` Gen int `json:"gen"` - Id string `json:"id"` Model string `json:"model"` Type string `json:"type"` - Mac string `json:"mac"` - App string `json:"app"` Auth bool `json:"auth"` AuthEn bool `json:"auth_en"` NumMeters int `json:"num_meters"` Profile string `json:"profile"` } - -type Gen2RpcPost struct { - Id int `json:"id"` - On bool `json:"on"` - Src string `json:"src"` - Method string `json:"method"` -} - -type Gen2SwitchResponse struct { - Output bool `json:"output"` -} - -type Gen2Switch struct { - Apower float64 - Aenergy struct { - Total float64 - } -} - -type Gen2Em struct { - Current float64 `json:"current"` - Voltage float64 `json:"voltage"` - ActPower float64 `json:"act_power"` -} - -type Gen2EmData struct { - TotalActEnergy float64 `json:"total_act_energy"` - TotalActRetEnergy float64 `json:"total_act_ret_energy"` -} - -type Gen2StatusResponse struct { - Switch0 Gen2Switch `json:"switch:0"` - Switch1 Gen2Switch `json:"switch:1"` - Switch2 Gen2Switch `json:"switch:2"` - Pm0 Gen2Switch `json:"pm1:0"` - Pm1 Gen2Switch `json:"pm2:1"` - Pm2 Gen2Switch `json:"pm3:2"` - // additional shelly Pro EM meter JSON response - TotalPower float64 `json:"total_act_power"` - CurrentA float64 `json:"a_current"` - CurrentB float64 `json:"b_current"` - CurrentC float64 `json:"c_current"` - VoltageA float64 `json:"a_voltage"` - VoltageB float64 `json:"b_voltage"` - VoltageC float64 `json:"c_voltage"` - PowerA float64 `json:"a_act_power"` - PowerB float64 `json:"b_act_power"` - PowerC float64 `json:"c_act_power"` - Em0 Gen2Em `json:"em1:0"` - Em1 Gen2Em `json:"em1:1"` - Em2 Gen2Em `json:"em1:2"` - Em0Data Gen2EmData `json:"em1data:0"` - Em1Data Gen2EmData `json:"em1data:1"` - Em2Data Gen2EmData `json:"em1data:2"` -} - -type Gen2EmDataStatusResponse struct { - TotalEnergy float64 `json:"total_act"` -} - -type Gen1SwitchResponse struct { - Ison bool -} - -type Gen1StatusResponse struct { - Meters []struct { - Power float64 - Total float64 - } - // Shelly EM meter JSON response - EMeters []struct { - Power float64 - Total float64 - } -} diff --git a/meter/shelly/types_test.go b/meter/shelly/types_test.go index 1a93e5ca3..f995e9a30 100644 --- a/meter/shelly/types_test.go +++ b/meter/shelly/types_test.go @@ -8,113 +8,6 @@ import ( "github.com/stretchr/testify/require" ) -// Test Gen1StatusResponse response -func TestUnmarshalGen1StatusResponse(t *testing.T) { - { - // Shelly 1 PM channel 0 (1) - var res Gen1StatusResponse - - jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-54},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"17:59","unixtime":1676134770,"serial":2437,"has_update":true,"mac":"84CCA8XXXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"source":"http"}],"meters":[{"power":4711.12,"overpower":0.00,"is_valid":true,"timestamp":1676138370,"counters":[0.000, 0.000, 0.000],"total":6472513}],"inputs":[{"input":0,"event":"","event_cnt":0}],"temperature":16.79,"overtemperature":false,"tmp":{"tC":16.79,"tF":62.22, "is_valid":true},"temperature_status":"Normal","ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221108-153925/v1.12.1-1PM-fix-g2821131","old_version":"20220209-094317/v1.11.8-g8c7bb8d"},"ram_total":50456,"ram_free":37056,"fs_size":233681,"fs_free":149094,"uptime":17284290}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - - assert.Equal(t, 107875.21666666666, gen1Energy("SHSW-PM", res.Meters[0].Total)) - assert.Equal(t, 4711.12, res.Meters[0].Power) - } - - { - // Shelly 1 channel 0 (1) - var res Gen1StatusResponse - - jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-57},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"19:25","unixtime":1676139913,"serial":959,"has_update":true,"mac":"E8DB8XXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"source":"timer"}],"meters":[{"power":81.5,"is_valid":true}],"inputs":[{"input":0,"event":"","event_cnt":0}],"ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221027-091427/v1.12.1-ga9117d3","old_version":"20211109-124958/v1.11.7-g682a0db"},"ram_total":50880,"ram_free":38796,"fs_size":233681,"fs_free":151102,"uptime":20319391}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - - assert.Equal(t, 0.0, gen1Energy("SHSW-1", res.Meters[0].Total)) - assert.Equal(t, 81.5, res.Meters[0].Power) - } - - { - // Shelly EM channel 0 (1) - var res Gen1StatusResponse - - jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX","ip":"192.168.178.XXX","rssi":-55},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"11:16","unixtime":1676110566,"serial":21580,"has_update":false,"mac":"C45BXXXXX","cfg_changed_cnt":0,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"is_valid":true,"source":"input"}],"emeters":[{"power":-620.34,"reactive":714.48,"pf":-0.66,"voltage":235.68,"is_valid":true,"total":401472.9,"total_returned":653673.7},{"power":0.00,"reactive":0.00,"pf":0.00,"voltage":235.68,"is_valid":true,"total":173411.3,"total_returned":294.2}],"update":{"status":"idle","has_update":false,"new_version":"20221027-105518/v1.12.1-ga9117d3","old_version":"20221027-105518/v1.12.1-ga9117d3"},"ram_total":51072,"ram_free":35660,"fs_size":233681,"fs_free":156373,"uptime":2226140}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - - assert.Equal(t, 401472.9, gen1Energy("SHEM", res.EMeters[0].Total)) - assert.Equal(t, -620.34, res.EMeters[0].Power) - } -} - -// Test Gen2+ status responses -func TestUnmarshalGen2StatusResponse(t *testing.T) { - { - // Shelly Pro 1PM channel 0 (1) - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"cloud":{"connected":true},"eth":{"ip":null},"input:0":{"id":0,"state":false},"input:1":{"id":1,"state":false},"mqtt":{"connected":false},"switch:0":{"id":0, "source":"HTTP", "output":false, "apower":47.11, "voltage":232.0, "current":0.000, "pf":0.00, "aenergy":{"total":5.125,"by_minute":[0.000,0.000,0.000],"minute_ts":1675718520},"temperature":{"tC":25.3, "tF":77.5}},"sys":{"mac":"30C6F78BB4D8","restart_required":false,"time":"22:22","unixtime":1675718522,"uptime":45070,"ram_size":234204,"ram_free":137716,"fs_size":524288,"fs_free":172032,"cfg_rev":13,"kvs_rev":1,"schedule_rev":0,"webhook_rev":0,"available_updates":{"beta":{"version":"0.13.0-beta3"}}},"wifi":{"sta_ip":"192.168.178.64","status":"got ip","ssid":"***","rssi":-62},"ws":{"connected":false}}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - - assert.Equal(t, 5.125, res.Switch0.Aenergy.Total) - assert.Equal(t, 47.11, res.Switch0.Apower) - } - - { - // Shelly PM Mini Gen3 channel 0 (1) - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"cloud":{"connected":true},"mqtt":{"connected":false},"pm1:0":{"id":0, "voltage":239.9, "current":7.434, "apower":1780.1 ,"freq":50.1,"aenergy":{"total":3551.682,"by_minute":[15234.772,29611.247,29825.821],"minute_ts":1719917850},"ret_aenergy":{"total":0.000,"by_minute":[0.000,0.000,0.000],"minute_ts":1719917850}},"sys":{"mac":"84FCE638D818","restart_required":false,"time":"12:57","unixtime":1719917851,"uptime":62328,"ram_size":261744,"ram_free":151436,"fs_size":1048576,"fs_free":712704,"cfg_rev":10,"kvs_rev":1,"schedule_rev":0,"webhook_rev":0,"available_updates":{"stable":{"version":"1.3.3"}},"reset_reason":1},"wifi":{"sta_ip":"192.168.178.89","status":"got ip","ssid":"FritzBox 8 2.4","rssi":-62},"ws":{"connected":false}}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - - assert.Equal(t, 3551.682, res.Pm0.Aenergy.Total) - assert.Equal(t, 1780.1, res.Pm0.Apower) - } - - { - // Shelly Pro 3EM - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"bthome":{"errors":["bluetooth_disabled"]},"cloud":{"connected":true},"em1:0":{"id":0,"current":3.705,"voltage":242.8,"act_power":598.9,"aprt_power":900.6,"pf":0.66,"freq":50.0,"calibration":"factory"},"em1:1":{"id":1,"current":0.194,"voltage":242.8,"act_power":0.0,"aprt_power":47.2,"pf":0.00,"freq":50.0,"calibration":"factory"},"em1:2":{"id":2,"current":0.027,"voltage":242.8,"act_power":0.0,"aprt_power":6.6,"pf":0.00,"freq":50.0,"calibration":"factory"},"em1data:0":{"id":0,"total_act_energy":3458.24,"total_act_ret_energy":1605.24},"em1data:1":{"id":1,"total_act_energy":2768.67,"total_act_ret_energy":25.49},"em1data:2":{"id":2,"total_act_energy":3.09,"total_act_ret_energy":0.71},"eth":{"ip":null},"modbus":{},"mqtt":{"connected":false},"sys":{"mac":"FCE8C0DBA850","restart_required":false,"time":"19:46","unixtime":1731404780,"uptime":563,"ram_size":247148,"ram_free":110596,"fs_size":524288,"fs_free":176128,"cfg_rev":21,"kvs_rev":0,"schedule_rev":3,"webhook_rev":1,"available_updates":{},"reset_reason":3},"temperature:0":{"id": 0,"tC":39.0, "tF":102.2},"wifi":{"sta_ip":"192.168.40.174","status":"got ip","ssid":"IoT","rssi":-67},"ws":{"connected":false}}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - // Channel 0 (1) - assert.Equal(t, 598.9, res.Em0.ActPower) - assert.Equal(t, 3.705, res.Em0.Current) - assert.Equal(t, 242.8, res.Em0.Voltage) - assert.Equal(t, 3458.24, res.Em0Data.TotalActEnergy) - assert.Equal(t, 1605.24, res.Em0Data.TotalActRetEnergy) - // Channel 1 (2) - assert.Equal(t, 0.0, res.Em1.ActPower) - assert.Equal(t, 0.194, res.Em1.Current) - assert.Equal(t, 242.8, res.Em1.Voltage) - assert.Equal(t, 2768.67, res.Em1Data.TotalActEnergy) - assert.Equal(t, 25.49, res.Em1Data.TotalActRetEnergy) - // Channel 2 (3) - assert.Equal(t, 0.0, res.Em2.ActPower) - assert.Equal(t, 0.027, res.Em2.Current) - assert.Equal(t, 242.8, res.Em2.Voltage) - assert.Equal(t, 3.09, res.Em2Data.TotalActEnergy) - assert.Equal(t, 0.71, res.Em2Data.TotalActRetEnergy) - } - - { - // Shelly Pro EM-50 channel 0 + 1 - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"bthome":{"errors":["bluetooth_disabled"]},"cloud":{"connected":true},"em1:0":{"id":0,"current":1.473,"voltage":226.9,"act_power":-332.2,"aprt_power":335.0,"pf":0.99,"freq":50.0,"calibration":"factory"},"em1:1":{"id":1,"current":0.428,"voltage":227.0,"act_power":-38.5,"aprt_power":97.4,"pf":0.38,"freq":50.0,"calibration":"factory"},"em1data:0":{"id":0,"total_act_energy":1264.15,"total_act_ret_energy":144792.28},"em1data:1":{"id":1,"total_act_energy":48002.83,"total_act_ret_energy":33241.59},"eth":{"ip":null},"modbus":{},"mqtt":{"connected":false},"switch:0":{"id":0, "source":"HTTP_in", "output":false,"temperature":{"tC":46.4, "tF":115.5}},"sys":{"mac":"08F9E0E8AF2C","restart_required":false,"time":"10:42","unixtime":1742809323,"uptime":3671372,"ram_size":249680,"ram_free":107492,"fs_size":524288,"fs_free":188416,"cfg_rev":12,"kvs_rev":0,"schedule_rev":1,"webhook_rev":0,"available_updates":{"beta":{"version":"1.5.1-beta2"}},"reset_reason":3},"wifi":{"sta_ip":"192.168.1.120","status":"got ip","ssid":"Spaetzlewerk","rssi":-61},"ws":{"connected":false}}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - // Channel 0 (1) - assert.Equal(t, -332.2, res.Em0.ActPower) - assert.Equal(t, 1.473, res.Em0.Current) - assert.Equal(t, 226.9, res.Em0.Voltage) - assert.Equal(t, 1264.15, res.Em0Data.TotalActEnergy) - assert.Equal(t, 144792.28, res.Em0Data.TotalActRetEnergy) - // Channel 1 (2) - assert.Equal(t, -38.5, res.Em1.ActPower) - assert.Equal(t, 0.428, res.Em1.Current) - assert.Equal(t, 227.0, res.Em1.Voltage) - assert.Equal(t, 48002.83, res.Em1Data.TotalActEnergy) - assert.Equal(t, 33241.59, res.Em1Data.TotalActRetEnergy) - } -} - // Test Shelly device info func TestUnmarshalDeviceInfoResponse(t *testing.T) { { @@ -124,7 +17,8 @@ func TestUnmarshalDeviceInfoResponse(t *testing.T) { jsonstr := `{"name":null,"id":"shellypro3em-fce8c0dba900","mac":"FCE8C0DBA900","slot":1,"model":"SPEM-003CEBEU","gen":2,"fw_id":"20241011-114455/1.4.4-g6d2a586","ver":"1.4.4","app":"Pro3EM","auth_en":false,"auth_domain":null,"profile":"monophase"}` require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - assert.Equal(t, "Pro3EM", res.App) + assert.Equal(t, 2, res.Gen) + assert.Equal(t, "SPEM-003CEBEU", res.Model) assert.Equal(t, "monophase", res.Profile) } }