diff --git a/ZigBeeValve/ZigBeeValve.go b/ZigBeeValve/ZigBeeValve.go index d0cd301..f03cba5 100644 --- a/ZigBeeValve/ZigBeeValve.go +++ b/ZigBeeValve/ZigBeeValve.go @@ -29,7 +29,7 @@ func main() { Certificate: "ABCD", Details: map[string][]string{"Developer": {"Arrowhead"}}, ProtoPort: map[string]int{"https": 0, "http": 8870, "coap": 0}, - InfoLink: "https://github.com/sdoque/systems/tree/master/ZigBeeValve", + InfoLink: "https://github.com/sdoque/systems/tree/master", } // instantiate a template unit asset @@ -55,7 +55,9 @@ func main() { log.Fatalf("Resource configuration error: %+v\n", err) } ua, startup := newResource(uac, &sys, servsTemp) - startup() + if err := startup(); err != nil { + log.Fatalf("Error during startup: %s\n", err) + } sys.UAssets[ua.GetName()] = &ua } @@ -80,32 +82,163 @@ func (t *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath switch servicePath { case "setpoint": t.setpt(w, r) + case "consumption": + t.consumption(w, r) + case "current": + t.current(w, r) + case "power": + t.power(w, r) + case "voltage": + t.voltage(w, r) + case "state": + t.state(w, r) default: http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) } } +// TODO: Add webhandler for power plug controller (sun up/down) and/or schedule later on. +// STRETCH GOAL: Instead of looking for specific models types, add a list of supported devices that we can check against + +// Function used by webhandler to either get or set the setpoint of a specific device func (rsc *UnitAsset) setpt(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": - setPointForm := rsc.getSetPoint() - usecases.HTTPProcessGetRequest(w, r, &setPointForm) - case "PUT": - sig, err := usecases.HTTPProcessSetRequest(w, r) - if err != nil { - http.Error(w, "Request incorrectly formatted", http.StatusBadRequest) + // Make sure only devices with setpoints actually support the http get method + if rsc.Model == "ZHAThermostat" || rsc.Model == "Smart plug" { + setPointForm := rsc.getSetPoint() + usecases.HTTPProcessGetRequest(w, r, &setPointForm) return } - - rsc.setSetPoint(sig) - if rsc.Model == "ZHAThermostat" { - err = rsc.sendSetPoint() + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + case "PUT": + // Make sure only devices with setpoints actually support the http put method + if rsc.Model == "ZHAThermostat" || rsc.Model == "Smart plug" { + sig, err := usecases.HTTPProcessSetRequest(w, r) if err != nil { - http.Error(w, "Couldn't send setpoint.", http.StatusInternalServerError) + http.Error(w, "Request incorrectly formatted", http.StatusBadRequest) return } + rsc.setSetPoint(sig) + return } + http.Error(w, "This device doesn't support that method.", http.StatusInternalServerError) + return default: http.Error(w, "Method is not supported.", http.StatusNotFound) } } + +// Function used by the webhandler to get the consumption of a device +func (rsc *UnitAsset) consumption(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with consumption sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + consumptionForm, err := rsc.getConsumption() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &consumptionForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +// Function used by the webhandler to get the power of a device +func (rsc *UnitAsset) power(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with power sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + powerForm, err := rsc.getPower() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &powerForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +// Function used by the webhandler to get the current of a device +func (rsc *UnitAsset) current(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with current sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + currentForm, err := rsc.getCurrent() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, ¤tForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +// Function used by the webhandler to get the voltage of a device +func (rsc *UnitAsset) voltage(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with voltage sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + voltageForm, err := rsc.getVoltage() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &voltageForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +func (rsc *UnitAsset) state(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + stateForm, err := rsc.getState() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &stateForm) + case "PUT": + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "Request incorrectly formatted", http.StatusBadRequest) + return + } + err = rsc.setState(sig) + if err != nil { + http.Error(w, "Something went wrong when setting state", http.StatusBadRequest) + return + } + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} diff --git a/ZigBeeValve/thing.go b/ZigBeeValve/thing.go index 8ab8268..bfb1ebc 100644 --- a/ZigBeeValve/thing.go +++ b/ZigBeeValve/thing.go @@ -11,10 +11,10 @@ import ( "io" "log" "net/http" + "strings" "time" - "github.com/coder/websocket" - // "github.com/coder/websocket/wsjson" + "github.com/gorilla/websocket" "github.com/sdoque/mbaigo/components" "github.com/sdoque/mbaigo/forms" "github.com/sdoque/mbaigo/usecases" @@ -40,12 +40,12 @@ type UnitAsset struct { ServicesMap components.Services `json:"-"` CervicesMap components.Cervices `json:"-"` // - Model string `json:"model"` - Uniqueid string `json:"uniqueid"` - deviceIndex string - Period time.Duration `json:"period"` - Setpt float64 `json:"setpoint"` - Apikey string `json:"APIkey"` + Model string `json:"model"` + Uniqueid string `json:"uniqueid"` + Period time.Duration `json:"period"` + Setpt float64 `json:"setpoint"` + Slaves map[string]string `json:"slaves"` + Apikey string `json:"APIkey"` } // GetName returns the name of the Resource. @@ -75,32 +75,72 @@ var _ components.UnitAsset = (*UnitAsset)(nil) // initTemplate initializes a UnitAsset with default values. func initTemplate() components.UnitAsset { + // This service will only be supported by Smart Thermostats and Smart Power plugs. setPointService := components.Service{ Definition: "setpoint", SubPath: "setpoint", Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, Description: "provides the current thermal setpoint (GET) or sets it (PUT)", } - /* - consumptionService := components.Service{ - Definition: "consumption", - SubPath: "consumption", - Details: map[string][]string{"Unit": {"Wh"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the current consumption of the device (GET)", - } - */ + + // This service will only be supported by Smart Power plugs (will be noted as sensors of type ZHAConsumption) + consumptionService := components.Service{ + Definition: "consumption", + SubPath: "consumption", + Details: map[string][]string{"Unit": {"Wh"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current consumption of the device in Wh (GET)", + } + + // This service will only be supported by Smart Power plugs (will be noted as sensors of type ZHAPower) + currentService := components.Service{ + Definition: "current", + SubPath: "current", + Details: map[string][]string{"Unit": {"mA"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current going through the device in mA (GET)", + } + + // This service will only be supported by Smart Power plugs (will be noted as sensors of type ZHAPower) + powerService := components.Service{ + Definition: "power", + SubPath: "power", + Details: map[string][]string{"Unit": {"W"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current consumption of the device in W (GET)", + } + + // This service will only be supported by Smart Power plugs (Will be noted as sensors of type ZHAPower) + voltageService := components.Service{ + Definition: "voltage", + SubPath: "voltage", + Details: map[string][]string{"Unit": {"V"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current voltage of the device in V (GET)", + } + + // This service will only be supported by Smart Power plugs (Will be noted as sensors of type ZHAPower) + stateService := components.Service{ + Definition: "state", + SubPath: "state", + Details: map[string][]string{"Unit": {"Binary"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current state of the device (GET), or sets it (PUT) [0 = off, 1 = on]", + } + // var uat components.UnitAsset // this is an interface, which we then initialize uat := &UnitAsset{ - Name: "SmartThermostat1", - Details: map[string][]string{"Location": {"Kitchen"}}, - Model: "ZHAThermostat", - Uniqueid: "14:ef:14:10:00:6f:d0:d7-11-1201", - deviceIndex: "", - Period: 10, - Setpt: 20, - Apikey: "1234", + Name: "SmartThermostat1", + Details: map[string][]string{"Location": {"Kitchen"}}, + Model: "ZHAThermostat", + Uniqueid: "14:ef:14:10:00:6f:d0:d7-11-1201", + Period: 10, + Setpt: 20, + // Only switches needs to manually add controlled power plug and light uniqueids, power plugs get their sensors added automatically + Slaves: map[string]string{}, + Apikey: "1234", ServicesMap: components.Services{ - setPointService.SubPath: &setPointService, + setPointService.SubPath: &setPointService, + consumptionService.SubPath: &consumptionService, + currentService.SubPath: ¤tService, + powerService.SubPath: &powerService, + voltageService.SubPath: &voltageService, + stateService.SubPath: &stateService, }, } return uat @@ -111,8 +151,8 @@ func initTemplate() components.UnitAsset { // newResource creates the resource with its pointers and channels based on the configuration using the tConfig structs // This is a startup function that's used to initiate the unit assets declared in the systemconfig.json, the function // that is returned is later used to send a setpoint/start a goroutine depending on model of the unitasset -func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) { - // determine the protocols that the system supports + +func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func() error) { sProtocols := components.SProtocols(sys.Husk.ProtoPort) // instantiate the consumed services @@ -129,14 +169,20 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi ServicesMap: components.CloneServices(servs), Model: uac.Model, Uniqueid: uac.Uniqueid, - deviceIndex: uac.deviceIndex, Period: uac.Period, Setpt: uac.Setpt, + Slaves: uac.Slaves, Apikey: uac.Apikey, CervicesMap: components.Cervices{ t.Name: t, }, } + + // Handles a panic caused by when this field is missing from the config file + if uac.Slaves == nil { + ua.Slaves = make(map[string]string) + } + var ref components.Service for _, s := range servs { if s.Definition == "setpoint" { @@ -144,37 +190,47 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi } } ua.CervicesMap["temperature"].Details = components.MergeDetails(ua.Details, ref.Details) + return ua, ua.startup +} - return ua, func() { - if ua.Model == "ZHAThermostat" { - /* - // Get correct index in list returned by api/sensors to make sure we always change correct device - err := ua.getConnectedUnits("sensors") - if err != nil { - log.Println("Error occurred during startup, while calling getConnectedUnits:", err) - } - */ - err := ua.sendSetPoint() - if err != nil { - log.Println("Error occurred during startup, while calling sendSetPoint():", err) - // TODO: Turn off system if this startup() fails? - } - } else if ua.Model == "Smart plug" { - /* - // Get correct index in list returned by api/lights to make sure we always change correct device - err := ua.getConnectedUnits("lights") - if err != nil { - log.Println("Error occurred during startup, while calling getConnectedUnits:", err) - } - */ - // Not all smart plugs should be handled by the feedbackloop, some should be handled by a switch - if ua.Period != 0 { - // start the unit assets feedbackloop, this fetches the temperature from ds18b20 and and toggles - // between on/off depending on temperature in the room and a set temperature in the unitasset - go ua.feedbackLoop(ua.Owner.Ctx) - } +func (ua *UnitAsset) startup() (err error) { + if websocketport == "startup" { + err = ua.getWebsocketPort() + if err != nil { + err = fmt.Errorf("getwebsocketport: %w", err) + return } } + + switch ua.Model { + case "ZHAThermostat": + err = ua.sendSetPoint() + if err != nil { + err = fmt.Errorf("ZHAThermostat sendsetpoint: %w", err) + return + } + + case "Smart plug": + // Find all sensors belonging to the smart plug and put them in the slaves array with + // their type as the key + err = ua.getSensors() + if err != nil { + err = fmt.Errorf("SmartPlug getsensors: %w", err) + return + } + // Not all smart plugs should be handled by the feedbackloop, some should be handled by a switch + if ua.Period > 0 { + // start the unit assets feedbackloop, this fetches the temperature from ds18b20 and and toggles + // between on/off depending on temperature in the room and a set temperature in the unitasset + go ua.feedbackLoop(ua.Owner.Ctx) + } + + case "ZHASwitch": + // Starts listening to the websocket to find buttonevents (button presses) and then + // turns its controlled devices (slaves) on/off + go ua.initWebsocketClient(ua.Owner.Ctx) + } + return } func (ua *UnitAsset) feedbackLoop(ctx context.Context) { @@ -223,10 +279,12 @@ var gateway string const discoveryURL string = "https://phoscon.de/discover" +var errBadFormValue error = fmt.Errorf("bad form value") var errStatusCode error = fmt.Errorf("bad status code") var errMissingGateway error = fmt.Errorf("missing gateway") var errMissingUniqueID error = fmt.Errorf("uniqueid not found") +// Function to find the gateway and save its ip and port (assuming there's only one) and return the error if one occurs func findGateway() (err error) { // https://pkg.go.dev/net/http#Get // GET https://phoscon.de/discover // to find gateways, array of JSONs is returned in http body, we'll only have one so take index 0 @@ -261,6 +319,46 @@ func findGateway() (err error) { //-------------------------------------Thing's resource methods +// Function to get sensors connected to a smart plug and place them in the "slaves" array +type sensorJSON struct { + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getSensors() (err error) { + // Create and send a get request to get all sensors connected to deConz gateway + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors" + req, err := createGetRequest(apiURL) + if err != nil { + return err + } + data, err := sendGetRequest(req) + if err != nil { + return err + } + // Unmarshal data from get request into an easy to use JSON format + var sensors map[string]sensorJSON + err = json.Unmarshal(data, &sensors) + if err != nil { + return err + } + // Take only the part of the mac address that is present in both the smart plug and the sensors + macAddr := ua.Uniqueid[0:23] + for _, sensor := range sensors { + uniqueid := sensor.UniqueID + check := strings.Contains(uniqueid, macAddr) + if check == true { + if sensor.Type == "ZHAConsumption" { + ua.Slaves["ZHAConsumption"] = sensor.UniqueID + } + if sensor.Type == "ZHAPower" { + ua.Slaves["ZHAPower"] = sensor.UniqueID + } + } + } + return +} + // getSetPoint fills out a signal form with the current thermal setpoint func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) { f.NewForm() @@ -275,134 +373,420 @@ func (ua *UnitAsset) setSetPoint(f forms.SignalA_v1a) { ua.Setpt = f.Value } +// Function to send a new setpoint of a device that has the "heatsetpoint" in its +// config (smart plug or smart thermostat) func (ua *UnitAsset) sendSetPoint() (err error) { - // API call to set desired temp in smart thermostat, PUT call should be sent to URL/api/apikey/sensors/sensor_id/config + // API call to set desired temp in smart thermostat, PUT call should be sent + // to URL/api/apikey/sensors/sensor_id/config // --- Send setpoint to specific unit --- apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Uniqueid + "/config" // Create http friendly payload s := fmt.Sprintf(`{"heatsetpoint":%f}`, ua.Setpt*100) // Create payload - req, err := createRequest(s, apiURL) + req, err := createPutRequest(s, apiURL) if err != nil { return } - return sendRequest(req) + return sendPutRequest(req) +} + +// Functions and structs to get and set current state of a smart plug/light +type plugJSON struct { + State struct { + On bool `json:"on"` + } `json:"state"` +} + +func (ua *UnitAsset) getState() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/lights/" + ua.Uniqueid + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + data, err := sendGetRequest(req) + var plug plugJSON + err = json.Unmarshal(data, &plug) + if err != nil { + return f, err + } + // Return a form containing current state in binary form (1 = on, 0 = off) + if plug.State.On == true { + f := getForm(1, "Binary") + return f, nil + } else { + f := getForm(0, "Binary") + return f, nil + } } +func (ua *UnitAsset) setState(f forms.SignalA_v1a) (err error) { + if f.Value == 0 { + return ua.toggleState(false) + } + if f.Value == 1 { + return ua.toggleState(true) + } + return errBadFormValue +} + +// Function to toggle the state of a specific device (power plug or light) on/off and return an error if it occurs func (ua *UnitAsset) toggleState(state bool) (err error) { - // API call to toggle smart plug on/off, PUT call should be sent to URL/api/apikey/lights/sensor_id/config + // API call to toggle light/smart plug on/off, PUT call should be sent to URL/api/apikey/lights/[light_id or plug_id]/state apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/lights/" + ua.Uniqueid + "/state" // Create http friendly payload s := fmt.Sprintf(`{"on":%t}`, state) // Create payload - req, err := createRequest(s, apiURL) + req, err := createPutRequest(s, apiURL) if err != nil { return } - return sendRequest(req) + return sendPutRequest(req) } -// Useless function? Noticed uniqueid can be used as "id" to send requests instead of the index while testing, wasn't clear from documentation. Will need to test this more though -// TODO: Rewrite this to instead get the websocketport. -func (ua *UnitAsset) getConnectedUnits(unitType string) (err error) { - // --- Get all devices --- - apiURL := fmt.Sprintf("http://%s/api/%s/%s", gateway, ua.Apikey, unitType) - // Create a new request (Get) - // Put data into buffer - req, err := http.NewRequest(http.MethodGet, apiURL, nil) // Put request is made - req.Header.Set("Content-Type", "application/json") // Make sure it's JSON - // Send the request +// Functions to create put or get request and return the *http.request and/or error if one occurs +func createPutRequest(data string, apiURL string) (req *http.Request, err error) { + body := bytes.NewReader([]byte(data)) // Put data into buffer + req, err = http.NewRequest(http.MethodPut, apiURL, body) // Put request is made + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") // Make sure it's JSON + return req, nil +} + +func createGetRequest(apiURL string) (req *http.Request, err error) { + req, err = http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") // Make sure it's JSON + return req, nil +} + +// A function to send a put request that returns the error if one occurs +func sendPutRequest(req *http.Request) (err error) { resp, err := http.DefaultClient.Do(req) // Perform the http request if err != nil { return err } defer resp.Body.Close() - resBody, err := io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes + _, err = io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes if err != nil { return } if resp.StatusCode > 299 { return errStatusCode } - // How to access maps inside of maps below! - // https://stackoverflow.com/questions/28806951/accessing-nested-map-of-type-mapstringinterface-in-golang - var deviceMap map[string]interface{} - err = json.Unmarshal([]byte(resBody), &deviceMap) + return +} + +// A function to send get requests and return the data received in the response body as a []byte and/or error if it happens +func sendGetRequest(req *http.Request) (data []byte, err error) { + resp, err := http.DefaultClient.Do(req) // Perform the http request if err != nil { - return + return nil, err } - // --- Find the index of a device with the specific UniqueID --- - for i := range deviceMap { - if deviceMap[i].(map[string]interface{})["uniqueid"] == ua.Uniqueid { - ua.deviceIndex = i - return - } + defer resp.Body.Close() + data, err = io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes + if err != nil { + return nil, err } - return errMissingUniqueID + if resp.StatusCode > 299 { + return nil, errStatusCode + } + return data, nil } -func createRequest(data string, apiURL string) (req *http.Request, err error) { - body := bytes.NewReader([]byte(data)) // Put data into buffer - req, err = http.NewRequest(http.MethodPut, apiURL, body) // Put request is made +// Creates a form that fills the fields of forms.SignalA_v1a with values from arguments and current time +func getForm(value float64, unit string) (f forms.SignalA_v1a) { + f.NewForm() + f.Value = value + f.Unit = fmt.Sprint(unit) + f.Timestamp = time.Now() + return f +} + +// ------------------------------------------------------------------------------------------------------------ +// IMPORTANT: lumi.plug.maeu01 HAS BEEN KNOWN TO GIVE BAD READINGS, BASICALLY STOP RESPONDING OR RESPOND WITH 0 +// They also don't appear for a long time after re-pairing devices to deConz +// ------------------------------------------------------------------------------------------------------------ + +// Struct and method to get and return a form containing current consumption (in Wh) +type consumptionJSON struct { + State struct { + Consumption uint64 `json:"consumption"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getConsumption() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAConsumption"] + // Create a get request + req, err := createGetRequest(apiURL) if err != nil { - return nil, err + return f, err } - req.Header.Set("Content-Type", "application/json") // Make sure it's JSON - return req, err + // Perform get request to sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) + if err != nil { + return f, err + } + // Unmarshal the body into usable json data + var data consumptionJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err + } + // Set form value to sensors value + value := float64(data.State.Consumption) + f = getForm(value, "Wh") + return f, nil } -func sendRequest(req *http.Request) (err error) { - resp, err := http.DefaultClient.Do(req) // Perform the http request +// Struct and method to get and return a form containing current power (in W) +type powerJSON struct { + State struct { + Power int16 `json:"power"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getPower() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAPower"] + // Create a get request + req, err := createGetRequest(apiURL) if err != nil { - return err + return f, err } - defer resp.Body.Close() - _, err = io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes + // Perform get request to sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) if err != nil { - return + return f, err } - if resp.StatusCode > 299 { - return errStatusCode + // Unmarshal the body into usable json data + var data powerJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err } - return + // Set form value to sensors value + value := float64(data.State.Power) + f = getForm(value, "W") + return f, nil +} + +// Struct and method to get and return a form containing current (in mA) +type currentJSON struct { + State struct { + Current uint16 `json:"current"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getCurrent() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAPower"] + // Create a get request + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + // Perform get request to sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) + if err != nil { + return f, err + } + // Unmarshal the body into usable json data + var data currentJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err + } + // Set form value to sensors value + value := float64(data.State.Current) + f = getForm(value, "mA") + return f, nil +} + +// Struct and method to get and return a form containing current voltage (in V) +type voltageJSON struct { + State struct { + Voltage uint16 `json:"voltage"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getVoltage() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAPower"] + // Create a get request + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + // Perform get request to power plug sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) + if err != nil { + return f, err + } + // Unmarshal the body into usable json data + var data voltageJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err + } + // Set form value to sensors value + value := float64(data.State.Voltage) + f = getForm(value, "V") + return f, nil } // --- HOW TO CONNECT AND LISTEN TO A WEBSOCKET --- -// Port 443, can be found by curl -v "http://localhost:8080/api/[apikey]/config", and getting the "websocketport". Will make a function to automatically get this port +// Port 443, can be found by curl -v "http://localhost:8080/api/[apikey]/config", and getting the "websocketport". // https://dresden-elektronik.github.io/deconz-rest-doc/endpoints/websocket/ // https://stackoverflow.com/questions/32745716/i-need-to-connect-to-an-existing-websocket-server-using-go-lang -// https://pkg.go.dev/github.com/coder/websocket#Dial -// https://pkg.go.dev/github.com/coder/websocket#Conn.Reader +// https://github.com/gorilla/websocket + +// In order for websocketport to run at startup i gave it something to check against and update +var websocketport = "startup" -// Not sure if this will work, still a work in progress. -func initWebsocketClient(ctx context.Context) (err error) { - fmt.Println("Starting Client") - ws, _, err := websocket.Dial(ctx, "ws://localhost:443", nil) // Start listening to websocket - defer ws.CloseNow() // Make sure connection is closed when returning from function +type eventJSON struct { + State struct { + Buttonevent int `json:"buttonevent"` + } `json:"state"` + UniqueID string `json:"uniqueid"` +} + +// This function sends a request for the config of the gateway, and saves the websocket port +// If an error occurs it will return that error +func (ua *UnitAsset) getWebsocketPort() (err error) { + // --- Get config --- + apiURL := fmt.Sprintf("http://%s/api/%s/config", gateway, ua.Apikey) + // Create a new request (Get) + req, err := http.NewRequest(http.MethodGet, apiURL, nil) // Put request is made if err != nil { - fmt.Printf("Dial failed: %s\n", err) return err } - _, body, err := ws.Reader(ctx) // Start reading from connection, returned body will be used to get buttonevents + // Make sure it's JSON + req.Header.Set("Content-Type", "application/json") + // Send the request + resp, err := http.DefaultClient.Do(req) if err != nil { - log.Println("Error while reading from websocket:", err) - return + return err } - data, err := io.ReadAll(body) + defer resp.Body.Close() + // Read the response body, and check for errors/bad statuscodes + resBody, err := io.ReadAll(resp.Body) if err != nil { - log.Println("Error while converthing from io.Reader to []byte:", err) - return + return err + } + if resp.StatusCode > 299 { + return errStatusCode + } + // How to access maps inside of maps below! + // https://stackoverflow.com/questions/28806951/accessing-nested-map-of-type-mapstringinterface-in-golang + var configMap map[string]interface{} + err = json.Unmarshal([]byte(resBody), &configMap) + if err != nil { + return err + } + websocketport = fmt.Sprint(configMap["websocketport"]) + return +} + +// STRETCH GOAL: Below can also be done with groups, could look into making groups for each switch, +// and then delete them on shutdown doing it with groups would make it so we don't +// have to keep track of a global variable and i think if unlucky only change one +// light or smart plug depending on reachability. Also first click currently always +// turn lights on, and then start working as intended. +// +// This function loops through the "slaves" of a unit asset, and sets them to either +// true (for on) and false (off), returning an error if it occurs. +func (ua *UnitAsset) toggleSlaves(currentState bool) (err error) { + var req *http.Request + for i := range ua.Slaves { + // API call to toggle smart plug or lights on/off, PUT call should be sent + // to URL/api/apikey/[sensors or lights]/sensor_id/config + apiURL := fmt.Sprintf("http://%s/api/%s/lights/%v/state", gateway, ua.Apikey, ua.Slaves[i]) + // Create http friendly payload + s := fmt.Sprintf(`{"on":%t}`, currentState) + req, err = createPutRequest(s, apiURL) + if err != nil { + return + } + if err = sendPutRequest(req); err != nil { + return + } } - var bodyString map[string]interface{} - err = json.Unmarshal(data, &bodyString) // Unmarshal body into json, easier to be able to point to specific data with ".example" + return +} + +// Function starts listening to a websocket, every message received through websocket is read, +// and checked if it's what we're looking for. +// The uniqueid (UniqueID in systemconfig.json file) from the connected switch is used to filter out messages +func (ua *UnitAsset) initWebsocketClient(ctx context.Context) { + dialer := websocket.Dialer{} + wsURL := fmt.Sprintf("ws://localhost:%s", websocketport) + conn, _, err := dialer.Dial(wsURL, nil) if err != nil { - log.Println("Error while unmarshalling data:", err) + log.Fatal("Error occurred while dialing websocket:", err) return } - log.Println("Read from websocket:", bodyString) - err = ws.Close(websocket.StatusNormalClosure, "No longer need to listen to websocket") + defer conn.Close() + currentState := false + + for { + select { + case <-ctx.Done(): // Shutdown + return + default: + // Read the message + // TODO: this is a blocking call! Might need to handle this read better, + // otherwise this goroutine might never be shutdown (from the context). + _, b, err := conn.ReadMessage() + if err != nil { + log.Println("Error occurred while reading message:", err) + return + } + currentState, err = ua.handleWebSocketMsg(currentState, b) + if err != nil { + log.Printf("Error handling websocket message: %s", err) + } + } + } +} + +func (ua *UnitAsset) handleWebSocketMsg(currentState bool, body []byte) (newState bool, err error) { + // Put it into a message variable of type eventJSON with "buttonevent" easily accessible + newState = currentState + var message eventJSON + err = json.Unmarshal(body, &message) if err != nil { - log.Println("Error while doing normal closure on websocket") + err = fmt.Errorf("unmarshall message: %w", err) return } + + if message.UniqueID == ua.Uniqueid { + // Depending on what buttonevent occurred, either turn the slaves on, or off + switch message.State.Buttonevent { + case 1002: // toggle the smart plugs/lights (lights) + newState = !currentState // Toggles the state between true/false + err = ua.toggleSlaves(newState) + if err != nil { + err = fmt.Errorf("toggle slaves to state %v: %w", newState, err) + return + } + + case 2002: + // TODO: Find out how "long presses" works and if it can be used through websocket + + default: + // Ignore any other events + } + } return - // Have to do something fancy to make sure we update "connected" plugs/lights when Reader returns a body actually containing a buttonevent (something w/ channels?) } diff --git a/ZigBeeValve/thing_test.go b/ZigBeeValve/thing_test.go index ee31ba7..2d70b3b 100644 --- a/ZigBeeValve/thing_test.go +++ b/ZigBeeValve/thing_test.go @@ -2,12 +2,12 @@ package main import ( "context" - "encoding/json" "fmt" "io" "net/http" "strings" "testing" + "time" "github.com/sdoque/mbaigo/components" "github.com/sdoque/mbaigo/forms" @@ -35,8 +35,6 @@ func newMockTransport(resp *http.Response, retErr bool, err error) mockTransport return t } -// TODO: this might need to be expanded to a full JSON array? - const discoverExample string = `[{ "Id": "123", "Internalipaddress": "localhost", @@ -165,7 +163,6 @@ var errBodyRead error = fmt.Errorf("bad body read") func (errReader) Read(p []byte) (n int, err error) { return 0, errBodyRead } - func (errReader) Close() error { return nil } @@ -231,6 +228,8 @@ func TestFindGateway(t *testing.T) { } } +var brokenURL string = string([]byte{0x7f}) + func TestToggleState(t *testing.T) { // Create mock response and unitasset for toggleState() function fakeBody := fmt.Sprint(`{"on":true, "Version": "SignalA_v1a"}`) @@ -274,172 +273,777 @@ func TestSendSetPoint(t *testing.T) { gateway = "localhost" } -type testJSON struct { - FirstAttr string `json:"firstAttr"` - Uniqueid string `json:"uniqueid"` - ThirdAttr string `json:"thirdAttr"` +func TestCreatePutRequest(t *testing.T) { + // Setup + data := "test" + apiURL := "http://localhost:8080/test" + + // --- Good test case: createPutRequest() --- + raw, err := createPutRequest(data, apiURL) + if err != nil { + t.Error("Error occurred, expected none") + } + body, err := io.ReadAll(raw.Body) + if string(body) != "test" { + t.Error("Error because body should be 'test', was: ", string(body)) + } + + // --- Bad test case: Error in createPutRequest() because of broken URL--- + raw, err = createPutRequest(data, brokenURL) + if err == nil { + t.Error("Expected error because of broken URL") + } } -func TestGetConnectedUnits(t *testing.T) { - gateway = "localhost" +func TestCreateGetRequest(t *testing.T) { + // Setup + apiURL := "http://localhost:8080/test" + + // --- Good test case: createGetRequest() --- + _, err := createGetRequest(apiURL) + if err != nil { + t.Error("Error occurred, expected none") + } + + // --- Bad test case: Error in createGetRequest() because of broken URL--- + _, err = createGetRequest(brokenURL) + if err == nil { + t.Error("Expected error because of broken URL") + } +} + +func TestSendPutRequests(t *testing.T) { // Set up standard response & catch http requests + fakeBody := fmt.Sprint(`Test`) + apiURL := "http://localhost:8080/test" resp := &http.Response{ Status: "200 OK", StatusCode: 200, - Body: nil, + Body: io.NopCloser(strings.NewReader(fakeBody)), } - ua := initTemplate().(*UnitAsset) - ua.Uniqueid = "123test" - // --- Broken body --- + // --- Good test case: sendPutRequest --- newMockTransport(resp, false, nil) + s := fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload + req, _ := createPutRequest(s, apiURL) + err := sendPutRequest(req) + if err != nil { + t.Error("Expected no errors, error occurred:", err) + } + + // Break defaultClient.Do() + // --- Error performing request --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + s = fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload + req, _ = createPutRequest(s, apiURL) + err = sendPutRequest(req) + if err == nil { + t.Error("Error expected while performing http request, got nil instead") + } + + // Error unpacking body resp.Body = errReader(0) - err := ua.getConnectedUnits(ua.Model) + newMockTransport(resp, false, nil) + + err = sendPutRequest(req) if err == nil { - t.Error("Expected error while unpacking body in getConnectedUnits()") + t.Error("Expected errors, no error occurred:") } - // --- All ok! --- - // Make a map - fakeBody := make(map[string]testJSON) - test := testJSON{ - FirstAttr: "123", - Uniqueid: "123test", - ThirdAttr: "456", + // Error StatusCode + resp.Body = io.NopCloser(strings.NewReader(fakeBody)) + resp.StatusCode = 300 + newMockTransport(resp, false, nil) + err = sendPutRequest(req) + if err != errStatusCode { + t.Error("Expected errStatusCode, got", err) } - // Insert the JSON into the map with key="1" - fakeBody["1"] = test - // Marshal and create response - jsonBody, _ := json.Marshal(fakeBody) - resp = &http.Response{ +} + +func TestSendGetRequest(t *testing.T) { + fakeBody := fmt.Sprint(`Test ok`) + apiURL := "http://localhost:8080/test" + resp := &http.Response{ Status: "200 OK", StatusCode: 200, - Body: io.NopCloser(strings.NewReader(string(jsonBody))), + Body: io.NopCloser(strings.NewReader(fakeBody)), } - // Start up a newMockTransport to capture HTTP requests before they leave + + // --- Good test case: sendGetRequest --- newMockTransport(resp, false, nil) - // Test function - err = ua.getConnectedUnits(ua.Model) + req, _ := createGetRequest(apiURL) + raw, err := sendGetRequest(req) if err != nil { t.Error("Expected no errors, error occurred:", err) } + data := string(raw) + if data != "Test ok" { + t.Error("Expected returned body to be 'Test ok', was: ", data) + } + + // Break defaultClient.Do() + // --- Error performing request --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + req, _ = createGetRequest(apiURL) + raw, err = sendGetRequest(req) + if err == nil { + t.Error("Error expected while performing http request, got nil instead") + } + + // Error unpacking body + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + req, _ = createGetRequest(apiURL) + raw, err = sendGetRequest(req) + if err == nil { + t.Error("Expected errors, no error occurred:") + } - // --- Bad statuscode --- + // Error StatusCode + resp.Body = io.NopCloser(strings.NewReader(fakeBody)) resp.StatusCode = 300 newMockTransport(resp, false, nil) - err = ua.getConnectedUnits(ua.Model) + req, _ = createGetRequest(apiURL) + raw, err = sendGetRequest(req) + if err != errStatusCode { + t.Error("Expected errStatusCode, got", err) + } +} + +func TestGetSensors(t *testing.T) { + // Setup for test + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "54:ef:44:10:00:d8:82:8d-01" + + zBeeResponse := `{ + "1": { + "state": {"consumption": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-02-000c", + "type": "ZHAConsumption" + }, + "2": { + "state": {"power": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-03-000c", + "type": "ZHAPower" + }}` + + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + + // --- Good case test--- + newMockTransport(zResp, false, nil) + ua.getSensors() + if ua.Slaves["ZHAConsumption"] != "54:ef:44:10:00:d8:82:8d-02-000c" { + t.Errorf("Error with ZHAConsumption, wrong mac addr.") + } + if ua.Slaves["ZHAPower"] != "54:ef:44:10:00:d8:82:8d-03-000c" { + t.Errorf("Error with ZHAPower, wrong mac addr.") + } + + // --- Bad case: Error on createGetRequest() using brokenURL (bad character) --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + err := ua.getSensors() if err == nil { - t.Errorf("Expected status code > 299 in getConnectedUnits(), got %v", resp.StatusCode) + t.Errorf("Expected an error during createGetRequest() because gateway is an invalid control char") } - // --- Missing uniqueid --- - // Make a map - fakeBody = make(map[string]testJSON) - test = testJSON{ - FirstAttr: "123", - Uniqueid: "missing", - ThirdAttr: "456", + // --- Bad case: Error while unmarshalling data --- + gateway = "localhost:8080" + FaultyzBeeResponse := `{ + "1": { + "state": {"consumption": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-02-000c"+123, + "type": "ZHAConsumption" + }, + "2": { + "state": {"power": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-03-000c"+123, + "type": "ZHAPower" + }}` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(FaultyzBeeResponse)), } - // Insert the JSON into the map with key="1" - fakeBody["1"] = test - // Marshal and create response - jsonBody, _ = json.Marshal(fakeBody) - resp = &http.Response{ + newMockTransport(zResp, false, nil) + err = ua.getSensors() + if err == nil { + t.Errorf("Expected error while unmarshalling data because of broken uniqueid field") + } + + // --- Bad case: Error while sending request --- + newMockTransport(zResp, false, fmt.Errorf("Test error")) + err = ua.getSensors() + if err == nil { + t.Errorf("Expected error during sendGetRequest()") + } +} + +func TestGetState(t *testing.T) { + // Setup for test + ua := initTemplate().(*UnitAsset) + gateway = "localhost:8080" + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "54:ef:44:10:00:d8:82:8d-01" + zBeeResponseTrue := `{"state": {"on": true}}` + zResp := &http.Response{ Status: "200 OK", StatusCode: 200, - Body: io.NopCloser(strings.NewReader(string(jsonBody))), + Body: io.NopCloser(strings.NewReader(zBeeResponseTrue)), } - // Start up a newMockTransport to capture HTTP requests before they leave - newMockTransport(resp, false, nil) - // Test function - err = ua.getConnectedUnits(ua.Model) - if err != errMissingUniqueID { - t.Error("Expected uniqueid to be missing when running getConnectedUnits()") + // --- Good test case: plug.State.On = true --- + newMockTransport(zResp, false, nil) + f, err := ua.getState() + if f.Value != 1 { + t.Errorf("Expected value to be 1, was %f", f.Value) + } + if err != nil { + t.Errorf("Expected no errors got: %v", err) } - // --- Unmarshall error --- - resp.Body = io.NopCloser(strings.NewReader(string(jsonBody) + "123")) - newMockTransport(resp, false, nil) - err = ua.getConnectedUnits(ua.Model) + // --- Good test case: plug.State.On = false --- + zBeeResponseFalse := `{"state": {"on": false}}` + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponseFalse)) + newMockTransport(zResp, false, nil) + f, err = ua.getState() + + if f.Value != 0 { + t.Errorf("Expected value to be 0, was %f", f.Value) + } + + if err != nil { + t.Errorf("Expected no errors got: %v", err) + } + + // --- Bad test case: Error on createGetRequest() --- + gateway = brokenURL + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponseTrue)) + newMockTransport(zResp, false, nil) + f, err = ua.getState() + if err == nil { - t.Error("Error expected during unmarshalling, got nil instead", err) + t.Errorf("Expected an error during createGetRequest() because gateway is an invalid control char") } - // --- Error performing request --- - newMockTransport(resp, false, fmt.Errorf("Test error")) - err = ua.getConnectedUnits(ua.Model) + gateway = "localhost:8080" + + // --- Bad test case: Error on unmarshal --- + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getState() + if err == nil { - t.Error("Error expected while performing http request, got nil instead") + t.Errorf("Expected an error while unmarshalling data") } } -// func createRequest(data string, apiURL string) (req *http.Request, err error) -func TestCreateRequest(t *testing.T) { - data := "test" - apiURL := "http://localhost:8080/test" +func TestSetState(t *testing.T) { + // Setup + gateway = "localhost:8080" + var f forms.SignalA_v1a + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "54:ef:44:10:00:d8:82:8d-01" + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("")), + } + // --- Good test case: f.Value = 1 --- + newMockTransport(zResp, false, nil) + f.NewForm() + f.Value = 1 + f.Timestamp = time.Now() + err := ua.setState(f) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + // --- Good test case: f.Value = 0 --- + newMockTransport(zResp, false, nil) + f.NewForm() + f.Value = 0 + f.Timestamp = time.Now() + err = ua.setState(f) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + // --- Bad test case: f.value is not 1 or 0 + newMockTransport(zResp, false, nil) + f.NewForm() + f.Value = 3 + f.Timestamp = time.Now() + err = ua.setState(f) + if err != errBadFormValue { + t.Errorf("Expected error because of f.Value not being 0 or 1") + } +} - _, err := createRequest(data, apiURL) +func TestGetConsumption(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAConsumption"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"consumption": 123}, "name": "consumptionTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getConsumption() if err != nil { - t.Error("Error occurred, expected none") + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "Wh" { + t.Errorf("Expected unit to be Wh, was: %s", f.Unit) } - _, err = createRequest(data, brokenURL) + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getConsumption() if err == nil { - t.Error("Expected error") + t.Errorf("Expected errors but got none (broken url)") } + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getConsumption() + if err == nil { + t.Errorf("Expected errors but got none (errReader body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getConsumption() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } } -var brokenURL string = string([]byte{0x7f}) +func TestGetPower(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getPower() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "W" { + t.Errorf("Expected unit to be W, was: %s", f.Unit) + } -func TestSendRequest(t *testing.T) { - // Set up standard response & catch http requests - fakeBody := fmt.Sprint(`Test`) + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getPower() + if err == nil { + t.Errorf("Expected errors but got none (broken url)") + } + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getPower() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getPower() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } +} + +func TestGetCurrent(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"current": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getCurrent() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "mA" { + t.Errorf("Expected unit to be mA, was: %s", f.Unit) + } + + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getCurrent() + if err == nil { + t.Errorf("Expected errors but got none (broken url)") + } + + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getCurrent() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getCurrent() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } +} + +func TestGetVoltage(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"voltage": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getVoltage() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "V" { + t.Errorf("Expected unit to be V, was: %s", f.Unit) + } + + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getVoltage() + if err == nil { + t.Errorf("Expected errors but got none (broken url)") + } + + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getVoltage() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getVoltage() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } +} + +func TestGetWebsocketPort(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "Switch1" + ua.Model = "ZHASwitch" + body := `{"test": "testing", "websocketport": "1010"}` resp := &http.Response{ Status: "200 OK", StatusCode: 200, - Body: io.NopCloser(strings.NewReader(fakeBody)), + Body: io.NopCloser(strings.NewReader(body)), } - // All ok! + // --- Good test case: all ok --- newMockTransport(resp, false, nil) - apiURL := "http://localhost:8080/test" - s := fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload - req, _ := createRequest(s, apiURL) - err := sendRequest(req) + websocketport = "test" + err := ua.getWebsocketPort() if err != nil { - t.Error("Expected no errors, error occurred:", err) + t.Errorf("Expected no errors, got: %v", err) + } + if websocketport != "1010" { + t.Errorf("Expected websocketport to be 1010, was: %s", websocketport) } - // Break defaultClient.Do() - // --- Error performing request --- + // --- Bad test case: Breaking new get request w/ broken url --- + gateway = brokenURL + newMockTransport(resp, false, nil) + websocketport = "test" + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors while creating new get request") + } + gateway = "localhost:8080" + + // --- Bad test case: Breaking http.DefaultClient.do() --- newMockTransport(resp, false, fmt.Errorf("Test error")) - s = fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload - req, _ = createRequest(s, apiURL) - err = sendRequest(req) + websocketport = "test" + err = ua.getWebsocketPort() if err == nil { - t.Error("Error expected while performing http request, got nil instead") + t.Error("Expected errors while performing the http request") } - // Error unpacking body + // --- Bad test case: bad body --- resp.Body = errReader(0) newMockTransport(resp, false, nil) + websocketport = "test" + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors during io.ReadAll (error body)") + } - err = sendRequest(req) + // --- Bad test case: bad statuscode --- + resp.Body = io.NopCloser(strings.NewReader(body)) + newMockTransport(resp, false, nil) + websocketport = "test" + resp.StatusCode = 300 + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors during io.ReadAll (bad statuscode)") + } + // --- Bad test case: Error unmarshalling body --- + badBody := `{"test": "testing", "websocketport": "1010"+123}` + resp.Body = io.NopCloser(strings.NewReader(badBody)) + newMockTransport(resp, false, nil) + websocketport = "test" + resp.StatusCode = 200 + err = ua.getWebsocketPort() if err == nil { - t.Error("Expected errors, no error occurred:") + t.Error("Expected errors during unmarshal") } +} - // Error StatusCode - resp.Body = io.NopCloser(strings.NewReader(fakeBody)) - resp.StatusCode = 300 +func TestToggleSlaves(t *testing.T) { + gateway = "localhost:8080" + websocketport = "443" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "14:ef:14:10:00:b2:b2:89-01" + ua.Slaves["ZHAConsumption"] = "14:ef:14:10:00:b2:b2:89-XX-XXX1" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-XX-XXX2" + + // -- Good test case: all ok --- + body := `{"status": "testing ok"}` + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } newMockTransport(resp, false, nil) - err = sendRequest(req) - if err != errStatusCode { - t.Error("Expected errStatusCode, got", err) + err := ua.toggleSlaves(true) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + + // --- Bad test case: error during createPutRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(resp, false, nil) + err = ua.toggleSlaves(true) + if err == nil { + t.Error("Expected error during createPutRequest (broken url)") + } + + // --- Bad test case: error during sendPutRequest() --- + gateway = "localhost:8080" + newMockTransport(resp, false, fmt.Errorf("Test error")) + ua.toggleSlaves(true) + if err == nil { + t.Error("Expected error during sendPutRequest") + } +} + +func TestHandleWebSocketMsg(t *testing.T) { + currentState := true + ua := initTemplate().(*UnitAsset) + ua.Name = "Switch1" + ua.Model = "ZHASwitch" + ua.Uniqueid = "14:ef:14:10:00:b2:b2:89-01" + ua.Slaves["Plug1"] = "34:ef:34:10:00:b2:b2:89-XX" + ua.Slaves["Plug2"] = "24:ef:24:10:00:b3:b3:89-XX" + message := []byte(`{"state": {"buttonevent": 1002}, "uniqueid": "14:ef:14:10:00:b2:b2:89-01"}`) + body := `{"status": "testing ok"}` + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + // --- Good test case: all ok --- + newMockTransport(resp, false, nil) + currentState, err := ua.handleWebSocketMsg(currentState, message) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + + // --- Bad test case: Unmarshal error --- + newMockTransport(resp, false, nil) + message = []byte(`{"state": {"buttonevent": 1002}, "uniqueid": "14:ef:14:10:00:b2:b2:89-01"}+123`) + currentState, err = ua.handleWebSocketMsg(currentState, message) + if err == nil { + t.Error("Expected errors during unmarshal, got none") + } + + // --- Bad test case: break toggleSlaves() --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + message = []byte(`{"state": {"buttonevent": 1002}, "uniqueid": "14:ef:14:10:00:b2:b2:89-01"}`) + currentState, err = ua.handleWebSocketMsg(currentState, message) + if err == nil { + t.Error("Expected errors during unmarshal, got none") + } +} + +func TestStartup(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Model = "test" + websocketport = "startup" + body := `{"websocketport": "1010"}` + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + // --- Good test case: getWebsocketPort only runs if websocketport="startup" and model not present in switchcase --- + websocketport = "notstartup" + newMockTransport(resp, false, nil) + err := ua.startup() + if err != nil { + t.Errorf("Expected no errors, got %v", err) + } + + // --- Bad test case: getWebsocketPort returns error --- + websocketport = "startup" + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + err = ua.startup() + if err == nil { + t.Errorf("Expected errors during getWebsocketPort, got none") + } + + // --- Good test case: getWebsocketPort running runs --- + resp.Body = io.NopCloser(strings.NewReader(body)) + newMockTransport(resp, false, nil) + err = ua.startup() + if err != nil { + t.Errorf("Expected no errors, got %v", err) + } + + // --- Good test case: ZHAThermostat switch case --- + ua.Model = "ZHAThermostat" + body = `{"test": "test ok"}` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + newMockTransport(resp, false, nil) + err = ua.startup() + if err != nil { + t.Errorf("Expected no errors in ZHAThermostat switch case, got: %v", err) + } + + // --- Bad test case: "ZHAThermostat" switch case --- + ua.Model = "ZHAThermostat" + body = `{"test": "test ok"}` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + err = ua.startup() + if err == nil { + t.Errorf(`Expected errors in "ZHAThermostat" switch case got none`) + } + + // --- Good test case: "Smart plug" switch case --- + ua.Model = "Smart plug" + ua.Period = -1 + body = `{"1": {"uniqueid": "ConsumptionTest", "type": "ZHAConsumption"}, "2": {"uniqueid": "PowerTest", "type": "ZHAPower"}}` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + newMockTransport(resp, false, nil) + err = ua.startup() + if err != nil { + t.Errorf(`Expected no errors in "Smart plug" switch case, got: %v`, err) } + // --- Bad test case: "Smart plug" switch case --- + newMockTransport(resp, false, nil) + resp.Body = errReader(0) + err = ua.startup() + if err == nil { + t.Errorf(`Expected errors in "Smart plug" switch case`) + } } diff --git a/ZigBeeValve/zigbee_test.go b/ZigBeeValve/zigbee_test.go index c9e4f72..1e07911 100644 --- a/ZigBeeValve/zigbee_test.go +++ b/ZigBeeValve/zigbee_test.go @@ -9,16 +9,16 @@ import ( "testing" ) +var good_code = 200 + func TestSetpt(t *testing.T) { + // --- ZHAThermostat --- ua := initTemplate().(*UnitAsset) - gateway = "localhost" - ua.deviceIndex = "1" // --- Good case test: GET --- w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "http://localhost:8670/ZigBee/SmartThermostat1/setpoint", nil) + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", nil) r.Header.Set("Content-Type", "application/json") - good_code := 200 ua.setpt(w, r) // Read response to a string, and save it in stringBody resp := w.Result() @@ -41,10 +41,21 @@ func TestSetpt(t *testing.T) { if version != true { t.Errorf("Good GET: Expected the version statement to be true!") } + // --- Good test case: not correct device type + ua.Model = "Wrong Device" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", nil) + ua.setpt(w, r) + // Read response and check statuscode + resp = w.Result() + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("Expected the status to be 500 but got: %v", resp.StatusCode) + } - // --- Bad test case: Default part of code (faulty http method) --- + // --- Default part of code (faulty http method) --- + ua = initTemplate().(*UnitAsset) w = httptest.NewRecorder() - r = httptest.NewRequest("123", "http://localhost:8670/ZigBee/SmartThermostat1/setpoint", nil) + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", nil) r.Header.Set("Content-Type", "application/json") ua.setpt(w, r) // Read response and check statuscode, expecting 404 (StatusNotFound) @@ -53,20 +64,35 @@ func TestSetpt(t *testing.T) { t.Errorf("Expected the status to be bad but got: %v", resp.StatusCode) } - // --- Bad PUT (Cant reach ZigBee) --- + // --- Good test case: PUT --- w = httptest.NewRecorder() - // Make the body + // Make the body and request fakebody := string(`{"value": 24, "version": "SignalA_v1.0"}`) sentBody := io.NopCloser(strings.NewReader(fakebody)) - // Send the request - r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBee/SmartThermostat1/setpoint", sentBody) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", sentBody) r.Header.Set("Content-Type", "application/json") + // Mock the http response/traffic to zigbee + zBeeResponse := `[{"success":{"/sensors/7/config/heatsetpoint":2400}}]` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(resp, false, nil) + // Set the response body to same as mock response + w.Body = bytes.NewBuffer([]byte(zBeeResponse)) + // Send the request ua.setpt(w, r) resp = w.Result() - resp.StatusCode = 404 // Simulate zigbee gateway not found? - // Check for errors, should not be 200 - if resp.StatusCode == good_code { - t.Errorf("Bad PUT: Expected bad status code: got %v.", resp.StatusCode) + // Check for errors + if resp.StatusCode != good_code { + t.Errorf("Good PUT: Expected good status code: %v, got %v", good_code, resp.StatusCode) + } + // Convert body to a string and check that it's correct + respBodyBytes, _ := io.ReadAll(resp.Body) + respBody := string(respBodyBytes) + if respBody != `[{"success":{"/sensors/7/config/heatsetpoint":2400}}]` { + t.Errorf("Wrong body") } // --- Bad test case: PUT Failing @ HTTPProcessSetRequest --- @@ -75,7 +101,7 @@ func TestSetpt(t *testing.T) { fakebody = string(`{"value": "24"`) // MISSING VERSION IN SENTBODY sentBody = io.NopCloser(strings.NewReader(fakebody)) // Send the request - r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBee/SmartThermostat1/setpoint", sentBody) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", sentBody) r.Header.Set("Content-Type", "application/json") ua.setpt(w, r) resp = w.Result() @@ -84,34 +110,513 @@ func TestSetpt(t *testing.T) { t.Errorf("Bad PUT: Expected an error during HTTPProcessSetRequest") } - // --- Good test case: PUT --- + // --- Bad PUT (Cant reach ZigBee) --- w = httptest.NewRecorder() - // Make the body and request + ua.Model = "Wrong device" + // Make the body fakebody = string(`{"value": 24, "version": "SignalA_v1.0"}`) sentBody = io.NopCloser(strings.NewReader(fakebody)) - r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBee/SmartThermostat1/setpoint", sentBody) + // Send the request + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", sentBody) r.Header.Set("Content-Type", "application/json") - // Mock the http response/traffic to zigbee - zBeeResponse := `[{"success":{"/sensors/7/config/heatsetpoint":2400}}]` - resp = &http.Response{ + ua.setpt(w, r) + resp = w.Result() + // Check for errors, should not be 200 + if resp.StatusCode == good_code { + t.Errorf("Bad PUT: Expected bad status code: got %v.", resp.StatusCode) + } +} + +func TestConsumption(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAConsumption"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + + zBeeResponse := `{ + "state": {"consumption": 1}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAConsumption" + }` + + zResp := &http.Response{ Status: "200 OK", StatusCode: 200, Body: io.NopCloser(strings.NewReader(zBeeResponse)), } - newMockTransport(resp, false, nil) - // Set the response body to same as mock response - w.Body = bytes.NewBuffer([]byte(zBeeResponse)) - // Send the request - ua.setpt(w, r) + newMockTransport(zResp, false, nil) + ua.consumption(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() + if resp.StatusCode != good_code { + t.Errorf("expected good status code: %v, got %v", good_code, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + stringBody := string(body) + // Check if correct values are present in the body, each line returns true/false + value := strings.Contains(string(stringBody), `"value": 1`) + unit := strings.Contains(string(stringBody), `"unit": "Wh"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.consumption(w, r) resp = w.Result() - // Check for errors + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + // --- Bad test case: error from getConsumption() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SnartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.consumption(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + ua.consumption(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } +} + +func TestPower(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + + zBeeResponse := `{ + "state": {"power": 2}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(zResp, false, nil) + ua.power(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() if resp.StatusCode != good_code { - t.Errorf("Good PUT: Expected good status code: %v, got %v", good_code, resp.StatusCode) + t.Errorf("expected good status code: %v, got %v", good_code, resp.StatusCode) } - // Convert body to a string and check that it's correct - respBodyBytes, _ := io.ReadAll(resp.Body) - respBody := string(respBodyBytes) - if respBody != `[{"success":{"/sensors/7/config/heatsetpoint":2400}}]` { - t.Errorf("Wrong body") + body, _ := io.ReadAll(resp.Body) + stringBody := string(body) + // Check if correct values are present in the body, each line returns true/false + value := strings.Contains(string(stringBody), `"value": 2`) + unit := strings.Contains(string(stringBody), `"unit": "W"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.power(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + + // --- Bad test case: error from getPower() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SnartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + newMockTransport(zResp, false, nil) + ua.power(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + ua.power(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } +} + +func TestCurrent(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/current", nil) + + zBeeResponse := `{ + "state": {"current": 3}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(zResp, false, nil) + ua.current(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() + if resp.StatusCode != good_code { + t.Errorf("expected good status code: %v, got %v", good_code, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + stringBody := string(body) + // Check if correct values are present in the body, each line returns true/false + value := strings.Contains(string(stringBody), `"value": 3`) + unit := strings.Contains(string(stringBody), `"unit": "mA"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.current(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + + // --- Bad test case: error from getPower() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SnartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + newMockTransport(zResp, false, nil) + ua.current(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + ua.current(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } +} + +func TestVoltage(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + zBeeResponse := `{ + "state": {"voltage": 4}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(zResp, false, nil) + ua.voltage(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() + if resp.StatusCode != good_code { + t.Errorf("expected good status code: %v, got %v", good_code, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + stringBody := string(body) + // Check if correct values are present in the body, each line returns true/false + value := strings.Contains(string(stringBody), `"value": 4`) + unit := strings.Contains(string(stringBody), `"unit": "V"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + newMockTransport(zResp, false, nil) + ua.voltage(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + + // --- Bad test case: error from getPower() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SmartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + newMockTransport(zResp, false, nil) + ua.voltage(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + ua.voltage(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } + +} + +func TestState(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + + zBeeResponse := `{ + "state": {"on": true}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + // --- Default part of code --- + newMockTransport(zResp, false, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.state(w, r) + res := w.Result() + _, err := io.ReadAll(res.Body) + if err != nil { + t.Error("Expected no errors") + } + if res.StatusCode != 404 { + t.Errorf("Expected no errors in default part of code, got: %d", res.StatusCode) + } + + // --- Good test case: GET --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.state(w, r) + res = w.Result() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Error("Expected no errors reading body") + } + stringBody := string(body) + value := strings.Contains(string(stringBody), `"value": 1`) + unit := strings.Contains(string(stringBody), `"unit": "Binary"`) + if value == false { + t.Error("Expected value to be 1, but wasn't") + } + if unit == false { + t.Error("Expected unit to be Binary, was something else") + } + + // --- Bad test case: GET Wrong model --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Wrong model" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 500 { + t.Errorf("Expected status code 500 w/ wrong model, was: %d", res.StatusCode) + } + + // --- Bad test case: GET Error from getState() --- + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 500 { + t.Errorf("Expected status code 500 w/ error from getState(), was: %d", res.StatusCode) + } + + // --- Good test case: PUT --- + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponse)) + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody := `{"value": 0, "version": "SignalA_v1.0"}` + sentBody := io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 200 { + t.Errorf("Expected status code 200, was: %d", res.StatusCode) + } + + // --- Bad test case: PUT Wrong model --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody = `{"value": 0, "version": "SignalA_v1.0"}` + sentBody = io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Wrong model" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 500 { + t.Errorf("Expected status code 500, was: %d", res.StatusCode) + } + + // --- Bad test case: PUT Incorrectly formatted form --- + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponse)) + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody = `{"value": a}` + sentBody = io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 400 { + t.Errorf("Expected status code to be 400, was %d", res.StatusCode) + } + + // --- Bad test case: PUT breaking setState() --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody = `{"value": 3, "version": "SignalA_v1.0"}` // Value 3 not supported + sentBody = io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 400 { + t.Errorf("Expected status code to be 400, was %d", res.StatusCode) } } diff --git a/go.mod b/go.mod index f17d075..72c88c2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/lmas/d0020e_code go 1.23 require ( - github.com/coder/websocket v1.8.12 + github.com/gorilla/websocket v1.5.3 github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 ) diff --git a/go.sum b/go.sum index e584cd1..4cc0449 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,13 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMz github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=