github.com/simpleiot/simpleiot@v0.18.3/client/shelly-io.go (about)

     1  package client
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"net/http"
     7  	"strconv"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/simpleiot/simpleiot/data"
    12  )
    13  
    14  // shellyIOConfig describes the configuration of a Shelly device
    15  type shellyIOConfig struct {
    16  	Name string `json:"name"`
    17  }
    18  
    19  type shellyGen2SysConfig struct {
    20  	Device struct {
    21  		Name string `json:"name"`
    22  	} `json:"device"`
    23  }
    24  
    25  // Example response
    26  // {"id":0, "source":"WS_in", "output":false, "apower":0.0, "voltage":123.3, "current":0.000, "aenergy":{"total":0.000,"by_minute":[0.000,0.000,0.000],"minute_ts":1680536525},"temperature":{"tC":44.4, "tF":112.0}}
    27  type shellyGen2SwitchStatus struct {
    28  	ID      int     `json:"id"`
    29  	Source  string  `json:"source"`
    30  	Output  bool    `json:"output"`
    31  	Apower  float32 `json:"apower"`
    32  	Voltage float32 `json:"voltage"`
    33  	Current float32 `json:"current"`
    34  	Aenergy struct {
    35  		Total    float32   `json:"total"`
    36  		ByMinute []float32 `json:"by_minute"`
    37  		MinuteTS int64     `json:"minute_ts"`
    38  	} `json:"aenergy"`
    39  	Temperature struct {
    40  		TC float32 `json:"tC"`
    41  		TF float32 `json:"tF"`
    42  	} `json:"temperature"`
    43  }
    44  
    45  type shellyGen2SwitchSetResp struct {
    46  	WasOn bool `json:"wasOn"`
    47  }
    48  
    49  func (swi *shellyGen2SwitchStatus) toPoints(index int) data.Points {
    50  	now := time.Now()
    51  	key := strconv.Itoa(index)
    52  	return data.Points{
    53  		{Time: now, Type: data.PointTypeSwitch, Key: key, Value: data.BoolToFloat(swi.Output)},
    54  		{Time: now, Type: data.PointTypePower, Key: key, Value: float64(swi.Apower)},
    55  		{Time: now, Type: data.PointTypeVoltage, Key: key, Value: float64(swi.Voltage)},
    56  		{Time: now, Type: data.PointTypeCurrent, Key: key, Value: float64(swi.Current)},
    57  		{Time: now, Type: data.PointTypeTemperature, Key: key, Value: float64(swi.Temperature.TC)},
    58  	}
    59  }
    60  
    61  // Example response
    62  // {"id":2,"state":true}
    63  type shellyGen2InputStatus struct {
    64  	ID    int  `json:"id"`
    65  	State bool `json:"state"`
    66  }
    67  
    68  func (in *shellyGen2InputStatus) toPoints() data.Points {
    69  	now := time.Now()
    70  	return data.Points{
    71  		{Time: now, Type: data.PointTypeInput,
    72  			Key:   strconv.Itoa(in.ID),
    73  			Value: data.BoolToFloat(in.State)},
    74  	}
    75  }
    76  
    77  type shellyGen1LightStatus struct {
    78  	Ison       bool `json:"ison"`
    79  	Brightness int  `json:"brightness"`
    80  	White      int  `json:"white"`
    81  	Temp       int  `json:"temp"`
    82  	Transition int  `json:"transition"`
    83  }
    84  
    85  func (sls *shellyGen1LightStatus) toPoints() data.Points {
    86  	now := time.Now()
    87  	return data.Points{
    88  		{Time: now, Type: data.PointTypeLight, Key: "0", Value: data.BoolToFloat(sls.Ison)},
    89  		{Time: now, Type: data.PointTypeBrightness, Key: "0", Value: float64(sls.Brightness)},
    90  		{Time: now, Type: data.PointTypeWhite, Key: "0", Value: float64(sls.White)},
    91  		{Time: now, Type: data.PointTypeLightTemp, Key: "0", Value: float64(sls.Temp)},
    92  		{Time: now, Type: data.PointTypeTransition, Key: "0", Value: float64(sls.Transition)},
    93  	}
    94  }
    95  
    96  func (sg2c shellyGen2SysConfig) toSettings() shellyIOConfig {
    97  	return shellyIOConfig{
    98  		Name: sg2c.Device.Name,
    99  	}
   100  }
   101  
   102  // ShellyIo describes the config/state for a shelly io
   103  type ShellyIo struct {
   104  	ID          string    `node:"id"`
   105  	Parent      string    `node:"parent"`
   106  	Description string    `point:"description"`
   107  	DeviceID    string    `point:"deviceID"`
   108  	Type        string    `point:"type"`
   109  	IP          string    `point:"ip"`
   110  	Value       []float64 `point:"value"`
   111  	ValueSet    []float64 `point:"valueSet"`
   112  	Switch      []bool    `point:"switch"`
   113  	SwitchSet   []bool    `point:"switchSet"`
   114  	Light       []bool    `point:"light"`
   115  	LightSet    []bool    `point:"lightSet"`
   116  	Input       []bool    `point:"input"`
   117  	Offline     bool      `point:"offline"`
   118  	Controlled  bool      `point:"controlled"`
   119  	Disabled    bool      `point:"disabled"`
   120  }
   121  
   122  // Desc gets the description of a Shelly IO
   123  func (sio *ShellyIo) Desc() string {
   124  	ret := sio.Type
   125  	if len(sio.Description) > 0 {
   126  		ret += ":" + sio.Description
   127  	}
   128  	return ret
   129  }
   130  
   131  var httpClient = &http.Client{Timeout: 10 * time.Second}
   132  
   133  // ShellyGen describes the generation of device (Gen1/Gen2)
   134  type ShellyGen int
   135  
   136  // Shelly Generations
   137  const (
   138  	ShellyGenUnknown ShellyGen = iota
   139  	ShellyGen1
   140  	ShellyGen2
   141  )
   142  
   143  var shellyGenMap = map[string]ShellyGen{
   144  	data.PointValueShellyTypeBulbDuo: ShellyGen1,
   145  	data.PointValueShellyTypeRGBW2:   ShellyGen1,
   146  	data.PointValueShellyType1PM:     ShellyGen1,
   147  	data.PointValueShellyTypePlugUS:  ShellyGen2,
   148  	data.PointValueShellyTypePlugUK:  ShellyGen2,
   149  	data.PointValueShellyTypePlugIT:  ShellyGen2,
   150  	data.PointValueShellyTypePlugS:   ShellyGen2,
   151  	data.PointValueShellyTypeI4:      ShellyGen2,
   152  	data.PointValueShellyTypePlus1:   ShellyGen2,
   153  	data.PointValueShellyTypePlus2PM: ShellyGen2,
   154  }
   155  
   156  // Gen 2 metadata
   157  
   158  // shellComp is used to describe shelly "components" a device may support
   159  type shellyComp struct {
   160  	name  string
   161  	count int
   162  }
   163  
   164  var shellyCompMap = map[string][]shellyComp{
   165  	data.PointValueShellyTypeBulbDuo: {{"light", 1}},
   166  	data.PointValueShellyType1PM:     {{"switch", 1}},
   167  	data.PointValueShellyTypeI4:      {{"input", 4}},
   168  	data.PointValueShellyTypePlugUS:  {{"switch", 1}},
   169  	data.PointValueShellyTypePlugUK:  {{"switch", 1}},
   170  	data.PointValueShellyTypePlugIT:  {{"switch", 1}},
   171  	data.PointValueShellyTypePlugS:   {{"switch", 1}},
   172  	data.PointValueShellyTypePlus1:   {{"switch", 1}, {"input", 1}},
   173  	data.PointValueShellyTypePlus2PM: {{"switch", 2}, {"input", 2}},
   174  }
   175  
   176  var shellySettableOnOff = map[string]bool{
   177  	data.PointValueShellyTypeBulbDuo: true,
   178  	data.PointValueShellyTypeRGBW2:   true,
   179  	data.PointValueShellyType1PM:     true,
   180  	data.PointValueShellyTypePlugUS:  true,
   181  	data.PointValueShellyTypePlugUK:  true,
   182  	data.PointValueShellyTypePlugIT:  true,
   183  	data.PointValueShellyTypePlugS:   true,
   184  	data.PointValueShellyTypePlus1:   true,
   185  	data.PointValueShellyTypePlus2PM: true,
   186  }
   187  
   188  // Gen returns generation of Shelly device
   189  func (sio *ShellyIo) Gen() ShellyGen {
   190  	gen, ok := shellyGenMap[sio.Type]
   191  	if !ok {
   192  		return ShellyGenUnknown
   193  	}
   194  
   195  	return gen
   196  }
   197  
   198  // IsSettableOnOff returns true if the device can be turned on/off
   199  func (sio *ShellyIo) IsSettableOnOff() bool {
   200  	settable := shellySettableOnOff[sio.Type]
   201  	return settable
   202  }
   203  
   204  // GetConfig returns the configuration of Shelly Device
   205  func (sio *ShellyIo) getConfig() (shellyIOConfig, error) {
   206  	switch sio.Gen() {
   207  	case ShellyGen1:
   208  		var ret shellyIOConfig
   209  		res, err := httpClient.Get("http://" + sio.IP + "/settings")
   210  		if err != nil {
   211  			return ret, err
   212  		}
   213  		defer res.Body.Close()
   214  		if res.StatusCode != http.StatusOK {
   215  			return ret, fmt.Errorf("Shelly GetConfig returned an error code: %v", res.StatusCode)
   216  		}
   217  
   218  		err = json.NewDecoder(res.Body).Decode(&ret)
   219  
   220  		return ret, err
   221  	case ShellyGen2:
   222  		var config shellyGen2SysConfig
   223  		res, err := httpClient.Get("http://" + sio.IP + "/rpc/Sys.GetConfig")
   224  		if err != nil {
   225  			return config.toSettings(), err
   226  		}
   227  		defer res.Body.Close()
   228  		if res.StatusCode != http.StatusOK {
   229  			return config.toSettings(), fmt.Errorf("Shelly GetConfig returned an error code: %v", res.StatusCode)
   230  		}
   231  
   232  		err = json.NewDecoder(res.Body).Decode(&config)
   233  		return config.toSettings(), err
   234  
   235  	default:
   236  		return shellyIOConfig{}, fmt.Errorf("Unsupported device: %v", sio.Type)
   237  	}
   238  }
   239  
   240  // SetOnOff sets on/off state of device
   241  // BulbDuo: http://10.0.0.130/light/0?turn=on
   242  // PlugUS: http://192.168.33.1/rpc/Switch.Set?id=0&on=true
   243  func (sio *ShellyIo) SetOnOff(comp string, index int, on bool) (data.Points, error) {
   244  	if len(comp) < 2 {
   245  		return nil, fmt.Errorf("Component must be specified")
   246  	}
   247  	_ = index
   248  	gen := sio.Gen()
   249  	switch gen {
   250  	case ShellyGen1:
   251  		onoff := "off"
   252  		if on {
   253  			onoff = "on"
   254  		}
   255  		url := fmt.Sprintf("http://%v/%v/%v?turn=%v", sio.IP, comp, index, onoff)
   256  		res, err := httpClient.Get(url)
   257  		if err != nil {
   258  			return data.Points{}, err
   259  		}
   260  		defer res.Body.Close()
   261  		if res.StatusCode != http.StatusOK {
   262  			return data.Points{}, fmt.Errorf("Shelly GetConfig returned an error code: %v", res.StatusCode)
   263  		}
   264  
   265  		var status shellyGen1LightStatus
   266  
   267  		err = json.NewDecoder(res.Body).Decode(&status)
   268  		if err != nil {
   269  			return data.Points{}, err
   270  		}
   271  		return status.toPoints(), nil
   272  	case ShellyGen2:
   273  		onValue := "false"
   274  		if on {
   275  			onValue = "true"
   276  		}
   277  
   278  		compCap := strings.ToUpper(string(comp[0])) + comp[1:]
   279  
   280  		url := fmt.Sprintf("http://%v/rpc/%v.Set?id=%v&on=%v", sio.IP, compCap, index, onValue)
   281  		res, err := httpClient.Get(url)
   282  		if err != nil {
   283  			return data.Points{}, err
   284  		}
   285  		defer res.Body.Close()
   286  		if res.StatusCode != http.StatusOK {
   287  			return data.Points{}, fmt.Errorf("Shelly Switch.Set returned an error code: %v", res.StatusCode)
   288  		}
   289  
   290  		var status shellyGen2SwitchSetResp
   291  
   292  		err = json.NewDecoder(res.Body).Decode(&status)
   293  		if err != nil {
   294  			return data.Points{}, err
   295  		}
   296  		return data.Points{}, nil
   297  
   298  	default:
   299  		return data.Points{}, nil
   300  	}
   301  }
   302  
   303  func (sio *ShellyIo) gen1GetLight(count int) (data.Points, error) {
   304  	ret := data.Points{}
   305  
   306  	for i := 0; i < count; i++ {
   307  		res, err := httpClient.Get("http://" + sio.IP + "/light/0")
   308  		if err != nil {
   309  			return data.Points{}, err
   310  		}
   311  		defer res.Body.Close()
   312  		if res.StatusCode != http.StatusOK {
   313  			return data.Points{}, fmt.Errorf("Shelly GetConfig returned an error code: %v", res.StatusCode)
   314  		}
   315  
   316  		var status shellyGen1LightStatus
   317  
   318  		err = json.NewDecoder(res.Body).Decode(&status)
   319  		if err != nil {
   320  			return data.Points{}, err
   321  		}
   322  
   323  		ret = append(ret, status.toPoints()...)
   324  	}
   325  
   326  	return ret, nil
   327  }
   328  
   329  func (sio *ShellyIo) gen2GetSwitch(count int) (data.Points, error) {
   330  	ret := data.Points{}
   331  
   332  	for i := 0; i < count; i++ {
   333  
   334  		url := fmt.Sprintf("http://%v/rpc/Switch.GetStatus?id=%v", sio.IP, i)
   335  
   336  		res, err := httpClient.Get(url)
   337  		if err != nil {
   338  			return data.Points{}, err
   339  		}
   340  		defer res.Body.Close()
   341  		if res.StatusCode != http.StatusOK {
   342  			return data.Points{}, fmt.Errorf("Shelly GetConfig returned an error code: %v", res.StatusCode)
   343  		}
   344  
   345  		var status shellyGen2SwitchStatus
   346  
   347  		err = json.NewDecoder(res.Body).Decode(&status)
   348  		if err != nil {
   349  			return data.Points{}, err
   350  		}
   351  		pts := status.toPoints(i)
   352  		ret = append(ret, pts...)
   353  	}
   354  
   355  	return ret, nil
   356  }
   357  
   358  func (sio *ShellyIo) gen2GetInput(count int) (data.Points, error) {
   359  	var points data.Points
   360  	for channel := 0; channel < count; channel++ {
   361  		res, err := httpClient.Get("http://" + sio.IP + "/rpc/Input.GetStatus?id=" + strconv.Itoa(channel))
   362  		if err != nil {
   363  			return data.Points{}, err
   364  		}
   365  		defer res.Body.Close()
   366  		if res.StatusCode != http.StatusOK {
   367  			return data.Points{}, fmt.Errorf("Shelly GetConfig returned an error code: %v", res.StatusCode)
   368  		}
   369  
   370  		var status shellyGen2InputStatus
   371  
   372  		err = json.NewDecoder(res.Body).Decode(&status)
   373  		if err != nil {
   374  			return data.Points{}, err
   375  		}
   376  
   377  		points = append(points, status.toPoints()...)
   378  	}
   379  
   380  	return points, nil
   381  }
   382  
   383  // GetStatus gets the current status of the device
   384  func (sio *ShellyIo) GetStatus() (data.Points, error) {
   385  	ret := data.Points{}
   386  
   387  	// TODO: this needs clean up some to be data driven instead of all the if statements
   388  	gen := sio.Gen()
   389  
   390  	if cnt := sio.getCompCount("switch"); cnt > 0 {
   391  		if gen == ShellyGen1 {
   392  			_ = gen
   393  			// TODO: need to add gen 1 support for switch status
   394  		}
   395  		if gen == ShellyGen2 {
   396  			pts, err := sio.gen2GetSwitch(cnt)
   397  			if err != nil {
   398  				return nil, err
   399  			}
   400  			ret = append(ret, pts...)
   401  		}
   402  	}
   403  
   404  	if cnt := sio.getCompCount("input"); cnt > 0 {
   405  		if gen == ShellyGen1 {
   406  			_ = gen
   407  			// TODO: need to add gen 1 support for input status
   408  		}
   409  		if gen == ShellyGen2 {
   410  			pts, err := sio.gen2GetInput(cnt)
   411  			if err != nil {
   412  				return nil, err
   413  			}
   414  			ret = append(ret, pts...)
   415  		}
   416  	}
   417  
   418  	if cnt := sio.getCompCount("light"); cnt > 0 {
   419  		if gen == ShellyGen1 {
   420  			pts, err := sio.gen1GetLight(cnt)
   421  			if err != nil {
   422  				return nil, err
   423  			}
   424  			ret = append(ret, pts...)
   425  		}
   426  	}
   427  
   428  	return ret, nil
   429  }
   430  
   431  type shellyGen2Response struct {
   432  	RestartRequired bool   `json:"restartRequired"`
   433  	Code            int    `json:"code"`
   434  	Message         string `json:"message"`
   435  }
   436  
   437  // SetName is use to set the name in a device
   438  func (sio *ShellyIo) SetName(name string) error {
   439  	switch sio.Gen() {
   440  	case ShellyGen1:
   441  		uri := fmt.Sprintf("http://%v/settings?name=%v", sio.IP, name)
   442  		uri = strings.Replace(uri, " ", "%20", -1)
   443  		res, err := httpClient.Get(uri)
   444  		if err != nil {
   445  			return err
   446  		}
   447  		defer res.Body.Close()
   448  		if res.StatusCode != http.StatusOK {
   449  			return fmt.Errorf("Shelly SetName returned an error code: %v", res.StatusCode)
   450  		}
   451  		// TODO: not sure how to test if it worked ...
   452  	case ShellyGen2:
   453  		uri := fmt.Sprintf("http://%v/rpc/Sys.Setconfig?config={\"device\":{\"name\":\"%v\"}}", sio.IP, name)
   454  		uri = strings.Replace(uri, " ", "%20", -1)
   455  		res, err := httpClient.Get(uri)
   456  		if err != nil {
   457  			return err
   458  		}
   459  		defer res.Body.Close()
   460  		if res.StatusCode != http.StatusOK {
   461  			return fmt.Errorf("Shelly SetName returned an error code: %v", res.StatusCode)
   462  		}
   463  		var ret shellyGen2Response
   464  		err = json.NewDecoder(res.Body).Decode(&ret)
   465  		if err != nil {
   466  			return err
   467  		}
   468  		if ret.Code != 0 || ret.Message != "" {
   469  			return fmt.Errorf("Error setting Shelly device %v name: %v", sio.Type, ret.Message)
   470  		}
   471  	default:
   472  		return fmt.Errorf("Error setting name: Unsupported device: %v", sio.Type)
   473  	}
   474  	return nil
   475  }
   476  
   477  // GetCompCount returns the number of components found in the device
   478  func (sio *ShellyIo) getCompCount(comp string) int {
   479  	comps, ok := shellyCompMap[sio.Type]
   480  	if !ok {
   481  		return 0
   482  	}
   483  
   484  	for _, c := range comps {
   485  		if c.name == comp {
   486  			return c.count
   487  		}
   488  	}
   489  
   490  	return 0
   491  }