diff --git a/ZigBeeValve/ZigBeeValve.go b/ZigBeeValve/ZigBeeValve.go index 12d44a5..ac0e411 100644 --- a/ZigBeeValve/ZigBeeValve.go +++ b/ZigBeeValve/ZigBeeValve.go @@ -21,11 +21,11 @@ func main() { defer cancel() // make sure all paths cancel the context to avoid context leak // instantiate the System - sys := components.NewSystem("ZigBee", ctx) + sys := components.NewSystem("ZigBeeHandler", ctx) // Instatiate the Capusle sys.Husk = &components.Husk{ - Description: " is a controller for smart thermostats connected with a RaspBee II", + Description: " is a controller for smart devices connected with a RaspBee II", Certificate: "ABCD", Details: map[string][]string{"Developer": {"Arrowhead"}}, ProtoPort: map[string]int{"https": 0, "http": 8870, "coap": 0}, @@ -37,6 +37,12 @@ func main() { assetName := assetTemplate.GetName() sys.UAssets[assetName] = &assetTemplate + // Find zigbee gateway and store it in a global variable for reuse + err := findGateway() + if err != nil { + log.Fatal("Error getting gateway, shutting down: ", err) + } + // Configure the system rawResources, servsTemp, err := usecases.Configure(&sys) if err != nil { @@ -48,8 +54,8 @@ func main() { if err := json.Unmarshal(raw, &uac); err != nil { log.Fatalf("Resource configuration error: %+v\n", err) } - ua, cleanup := newResource(uac, &sys, servsTemp) - defer cleanup() + ua, startup := newResource(uac, &sys, servsTemp) + startup() sys.UAssets[ua.GetName()] = &ua } @@ -75,7 +81,7 @@ func (t *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath case "setpoint": t.setpt(w, r) default: - http.Error(w, "Invalid service request [Do not modify the services subpath in the configurration file]", http.StatusBadRequest) + http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) } } @@ -87,16 +93,18 @@ func (rsc *UnitAsset) setpt(w http.ResponseWriter, r *http.Request) { case "PUT": sig, err := usecases.HTTPProcessSetRequest(w, r) if err != nil { - log.Println("Error with the setting desired temp ", err) + http.Error(w, "Request incorrectly formated", http.StatusBadRequest) + return } - log.Println("sig:", sig) - log.Println("URL:", r.URL) - log.Println("Model:", rsc.Model) + rsc.setSetPoint(sig) - if rsc.Model == "SmartThermostat" { - rsc.sendSetPoint() + if rsc.Model == "ZHAThermostat" { + err = rsc.sendSetPoint() + if err != nil { + http.Error(w, "Couldn't send setpoint.", http.StatusInternalServerError) + return + } } - default: http.Error(w, "Method is not supported.", http.StatusNotFound) } diff --git a/ZigBeeValve/thing.go b/ZigBeeValve/thing.go index d62ef63..59c337c 100644 --- a/ZigBeeValve/thing.go +++ b/ZigBeeValve/thing.go @@ -13,13 +13,14 @@ import ( "net/http" "time" + "github.com/coder/websocket" + // "github.com/coder/websocket/wsjson" "github.com/sdoque/mbaigo/components" "github.com/sdoque/mbaigo/forms" "github.com/sdoque/mbaigo/usecases" ) -//------------------------------------ Used when discovering the gateway - +// ------------------------------------ Used when discovering the gateway type discoverJSON struct { Id string `json:"id"` Internalipaddress string `json:"internalipaddress"` @@ -39,11 +40,12 @@ type UnitAsset struct { ServicesMap components.Services `json:"-"` CervicesMap components.Cervices `json:"-"` // - Model string `json:"model"` - Period time.Duration `json:"period"` - Setpt float64 `json:"setpoint"` - gateway string - Apikey string `json:"APIkey"` + Model string `json:"model"` + Uniqueid string `json:"uniqueid"` + deviceIndex string + Period time.Duration `json:"period"` + Setpt float64 `json:"setpoint"` + Apikey string `json:"APIkey"` } // GetName returns the name of the Resource. @@ -79,16 +81,24 @@ func initTemplate() components.UnitAsset { 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)", + } + */ // var uat components.UnitAsset // this is an interface, which we then initialize uat := &UnitAsset{ - Name: "2", - Details: map[string][]string{"Location": {"Kitchen"}}, - Model: "", - Period: 10, - Setpt: 20, - gateway: "", - Apikey: "", + 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", ServicesMap: components.Services{ setPointService.SubPath: &setPointService, }, @@ -98,7 +108,9 @@ func initTemplate() components.UnitAsset { //-------------------------------------Instatiate the unit assets based on configuration -// newResource creates the Resource resource with its pointers and channels based on the configuration using the tConig structs +// 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()) { // deterimine the protocols that the system supports sProtocols := components.SProtocols(sys.Husk.ProtoPort) @@ -109,7 +121,6 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi Protos: sProtocols, Url: make([]string, 0), } - // intantiate the unit asset ua := &UnitAsset{ Name: uac.Name, @@ -117,35 +128,48 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi Details: uac.Details, ServicesMap: components.CloneServices(servs), Model: uac.Model, + Uniqueid: uac.Uniqueid, + deviceIndex: uac.deviceIndex, Period: uac.Period, Setpt: uac.Setpt, - gateway: uac.gateway, Apikey: uac.Apikey, CervicesMap: components.Cervices{ t.Name: t, }, } - - findGateway(ua) - var ref components.Service for _, s := range servs { if s.Definition == "setpoint" { ref = s } } - ua.CervicesMap["temperature"].Details = components.MergeDetails(ua.Details, ref.Details) - if uac.Model == "SmartThermostat" { - ua.sendSetPoint() - } else if uac.Model == "SmartPlug" { - // start the unit asset(s) - go ua.feedbackLoop(sys.Ctx) - } - return ua, func() { - log.Println("Shutting down zigbeevalve ", ua.Name) + 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 occured during startup, while calling getConnectedUnits:", err) + } + err = ua.sendSetPoint() + if err != nil { + log.Println("Error occured 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 occured 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) + } + } } } @@ -153,7 +177,6 @@ func (ua *UnitAsset) feedbackLoop(ctx context.Context) { // Initialize a ticker for periodic execution ticker := time.NewTicker(ua.Period * time.Second) defer ticker.Stop() - // start the control loop for { select { @@ -172,54 +195,64 @@ func (ua *UnitAsset) processFeedbackLoop() { log.Printf("\n unable to obtain a temperature reading error: %s\n", err) return } - // Perform a type assertion to convert the returned Form to SignalA_v1a tup, ok := tf.(*forms.SignalA_v1a) if !ok { log.Println("problem unpacking the temperature signal form") return } - // TODO: Check diff instead of a hard over/under value? meaning it'll only turn on/off if diff is over 0.5 degrees if tup.Value < ua.Setpt { - ua.toggleState(true) + err = ua.toggleState(true) + if err != nil { + log.Println("Error occured while toggling state to true: ", err) + } } else { - ua.toggleState(false) + err = ua.toggleState(false) + if err != nil { + log.Println("Error occured while toggling state to false: ", err) + } } - //log.Println("Feedback loop done.") - } -func findGateway(ua *UnitAsset) { +var gateway string + +const discoveryURL string = "https://phoscon.de/discover" + +var errStatusCode error = fmt.Errorf("bad status code") +var errMissingGateway error = fmt.Errorf("missing gateway") +var errMissingUniqueID error = fmt.Errorf("uniqueid not found") + +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 // GET the gateway through phoscons built in discover tool, the get will return a response, and in its body an array with JSON elements // ours is index 0 since there's no other RaspBee/ZigBee gateways on the network - res, err := http.Get("https://phoscon.de/discover") + res, err := http.Get(discoveryURL) if err != nil { - log.Println("Couldn't get gateway, error:", err) + return } defer res.Body.Close() if res.StatusCode > 299 { - log.Printf("Response failed with status code: %d and\n", res.StatusCode) + return errStatusCode } body, err := io.ReadAll(res.Body) // Read the payload into body variable if err != nil { - log.Println("Something went wrong while reading the body during discovery, error:", err) + return } var gw []discoverJSON // Create a list to hold the gateway json err = json.Unmarshal(body, &gw) // "unpack" body from []byte to []discoverJSON, save errors if err != nil { - log.Println("Error during Unmarshal, error:", err) + return } + // If the returned list is empty, return a missing gateway error if len(gw) < 1 { - log.Println("No gateway was found") - return + return errMissingGateway } - // Save the gateway to our unitasset + // Save the gateway s := fmt.Sprintf(`%s:%d`, gw[0].Internalipaddress, gw[0].Internalport) - ua.gateway = s - //log.Println("Gateway found:", s) + gateway = s + return } //-------------------------------------Thing's resource methods @@ -228,7 +261,7 @@ func findGateway(ua *UnitAsset) { func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) { f.NewForm() f.Value = ua.Setpt - f.Unit = "Celcius" + f.Unit = "Celsius" f.Timestamp = time.Now() return f } @@ -236,54 +269,136 @@ func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) { // setSetPoint updates the thermal setpoint func (ua *UnitAsset) setSetPoint(f forms.SignalA_v1a) { ua.Setpt = f.Value - log.Println("*---------------------*") - log.Printf("New set point: %.1f\n", f.Value) - log.Println("*---------------------*") } -func (ua *UnitAsset) sendSetPoint() { +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 - apiURL := "http://" + ua.gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Name + "/config" - + // --- Send setpoint to specific unit --- + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.deviceIndex + "/config" // Create http friendly payload s := fmt.Sprintf(`{"heatsetpoint":%f}`, ua.Setpt*100) // Create payload - data := []byte(s) // Turned into byte array - sendRequest(data, apiURL) + req, err := createRequest(s, apiURL) + if err != nil { + return + } + return sendRequest(req) } -func (ua *UnitAsset) toggleState(state bool) { - // API call to set desired temp in smart thermostat, PUT call should be sent to URL/api/apikey/sensors/sensor_id/config - apiURL := "http://" + ua.gateway + "/api/" + ua.Apikey + "/lights/" + ua.Name + "/state" - +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 + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/lights/" + ua.deviceIndex + "/state" // Create http friendly payload s := fmt.Sprintf(`{"on":%t}`, state) // Create payload - data := []byte(s) // Turned into byte array - sendRequest(data, apiURL) + req, err := createRequest(s, apiURL) + if err != nil { + return + } + return sendRequest(req) } -func sendRequest(data []byte, apiURL string) { - body := bytes.NewBuffer(data) // Put data into buffer - - req, err := http.NewRequest(http.MethodPut, apiURL, body) // Put request is made +// 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 + 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 if err != nil { - log.Println("Error making new HTTP PUT request, error:", err) 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) + if err != nil { + return + } + // --- 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 + } + } + return errMissingUniqueID +} +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 + if err != nil { + return nil, err + } req.Header.Set("Content-Type", "application/json") // Make sure it's JSON + return req, err +} - client := &http.Client{} // Make a client - resp, err := client.Do(req) // Perform the put request +func sendRequest(req *http.Request) (err error) { + resp, err := http.DefaultClient.Do(req) // Perform the http request if err != nil { - log.Println("Error sending HTTP PUT request, error:", err) - return + return err } defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) // Read the payload into body variable + _, err = io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes if err != nil { - log.Println("Something went wrong while reading the body during discovery, error:", err) + return } if resp.StatusCode > 299 { - log.Printf("Response failed with status code: %d and\nbody: %s\n", resp.StatusCode, string(b)) + return errStatusCode + } + return +} + +// --- 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 +// 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 + +// 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 + 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 + if err != nil { + log.Println("Error while reading from websocket:", err) + return + } + data, err := io.ReadAll(body) + if err != nil { + log.Println("Error while converthing from io.Reader to []byte:", err) + 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" + if err != nil { + log.Println("Error while unmarshaling data:", err) + return + } + log.Println("Read from websocket:", bodyString) + err = ws.Close(websocket.StatusNormalClosure, "No longer need to listen to websocket") + if err != nil { + log.Println("Error while doing normal closure on websocket") + return } + 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 new file mode 100644 index 0000000..e4f6aab --- /dev/null +++ b/ZigBeeValve/thing_test.go @@ -0,0 +1,445 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/forms" +) + +// mockTransport is used for replacing the default network Transport (used by +// http.DefaultClient) and it will intercept network requests. + +type mockTransport struct { + returnError bool + resp *http.Response + hits map[string]int + err error +} + +func newMockTransport(resp *http.Response, retErr bool, err error) mockTransport { + t := mockTransport{ + returnError: retErr, + resp: resp, + hits: make(map[string]int), + err: err, + } + // Highjack the default http client so no actuall http requests are sent over the network + http.DefaultClient.Transport = t + return t +} + +// TODO: this might need to be expanded to a full JSON array? + +const discoverExample string = `[{ + "Id": "123", + "Internalipaddress": "localhost", + "Macaddress": "test", + "Internalport": 8080, + "Name": "My gateway", + "Publicipaddress": "test" + }]` + +// RoundTrip method is required to fulfil the RoundTripper interface (as required by the DefaultClient). +// It prevents the request from being sent over the network and count how many times +// a domain was requested. + +var errHTTP error = fmt.Errorf("bad http request") + +func (t mockTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + t.hits[req.URL.Hostname()] += 1 + if t.err != nil { + return nil, t.err + } + if t.returnError != false { + req.GetBody = func() (io.ReadCloser, error) { + return nil, errHTTP + } + } + t.resp.Request = req + return t.resp, nil +} + +//////////////////////////////////////////////////////////////////////////////// + +func TestUnitAsset(t *testing.T) { + // Create a form + f := forms.SignalA_v1a{ + Value: 27.0, + } + // Creates a single UnitAsset and assert it changes + ua := initTemplate().(*UnitAsset) + // Change Setpt + ua.setSetPoint(f) + if ua.Setpt != 27.0 { + t.Errorf("Expected Setpt to be 27.0, instead got %f", ua.Setpt) + } + // Fetch Setpt w/ a form + f2 := ua.getSetPoint() + if f2.Value != f.Value { + t.Errorf("Expected %f, instead got %f", f.Value, f2.Value) + } +} + +func TestGetters(t *testing.T) { + ua := initTemplate().(*UnitAsset) + // Test GetName() + name := ua.GetName() + if name != "SmartThermostat1" { + t.Errorf("Expected name to be SmartThermostat1, instead got %s", name) + } + // Test GetServices() + services := ua.GetServices() + if services == nil { + t.Fatalf("Expected services not to be nil") + } + if services["setpoint"].Definition != "setpoint" { + t.Errorf("Expected definition to be setpoint") + } + // Test GetDetails() + details := ua.GetDetails() + if details == nil { + t.Fatalf("Details was nil, expected map") + } + if len(details["Location"]) == 0 { + t.Fatalf("Location was nil, expected kitchen") + } + if details["Location"][0] != "Kitchen" { + t.Errorf("Expected location to be Kitchen") + } + // Test GetCervices() + cervices := ua.GetCervices() + if cervices != nil { + t.Errorf("Expected no cervices") + } +} + +func TestNewResource(t *testing.T) { + // Setup test context, system and unitasset + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sys := components.NewSystem("testsys", ctx) + sys.Husk = &components.Husk{ + Description: " is a controller for smart thermostats connected with a RaspBee II", + 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", + } + 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)", + } + uac := UnitAsset{ + Name: "SmartThermostat1", + Details: map[string][]string{"Location": {"Kitchen"}}, + Model: "ZHAThermostat", + Period: 10, + Setpt: 20, + Apikey: "1234", + ServicesMap: components.Services{ + setPointService.SubPath: &setPointService, + }, + } + // Test newResource function + ua, _ := newResource(uac, &sys, nil) + // Happy test case: + name := ua.GetName() + if name != "SmartThermostat1" { + t.Errorf("Expected name to be SmartThermostat1, but instead got: %v", name) + } +} + +type errReader int + +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 +} + +func TestFindGateway(t *testing.T) { + // Create mock response for findGateway function + fakeBody := fmt.Sprint(discoverExample) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + newMockTransport(resp, false, nil) + + // ---- All ok! ---- + err := findGateway() + if err != nil { + t.Fatal("Gateway not found", err) + } + if gateway != "localhost:8080" { + t.Fatalf("Expected gateway to be localhost:8080, was %s", gateway) + } + + // ---- Error cases ---- + // Unmarshall error + newMockTransport(resp, false, fmt.Errorf("Test error")) + err = findGateway() + if err == nil { + t.Error("Error expcted during unmarshalling, got nil instead", err) + } + + // Statuscode > 299, have to make changes to mockTransport to test this + resp.StatusCode = 300 + newMockTransport(resp, false, nil) + err = findGateway() + if err != errStatusCode { + t.Error("Expected errStatusCode, got", err) + } + + // Broken body - https://stackoverflow.com/questions/45126312/how-do-i-test-an-error-on-reading-from-a-request-body + resp.StatusCode = 200 + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + err = findGateway() + if err != errBodyRead { + t.Error("Expected errBodyRead, got", err) + } + + // Actual http body is unmarshaled incorrectly + resp.Body = io.NopCloser(strings.NewReader(fakeBody + "123")) + newMockTransport(resp, false, nil) + err = findGateway() + if err == nil { + t.Error("Expected error while unmarshalling body, error:", err) + } + + // Empty list of gateways + resp.Body = io.NopCloser(strings.NewReader("[]")) + newMockTransport(resp, false, nil) + err = findGateway() + if err != errMissingGateway { + t.Error("Expected error when list of gateways is empty:", err) + } +} + +func TestToggleState(t *testing.T) { + // Create mock response and unitasset for toggleState() function + fakeBody := fmt.Sprint(`{"on":true, "Version": "SignalA_v1a"}`) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + newMockTransport(resp, false, nil) + ua := initTemplate().(*UnitAsset) + // All ok! + ua.toggleState(true) + // Error + // change gateway to bad character/url, return gateway to original value + gateway = brokenURL + ua.toggleState(true) + findGateway() +} + +func TestSendSetPoint(t *testing.T) { + // Create mock response and unitasset for sendSetPoint() function + fakeBody := fmt.Sprint(`{"Value": 12.4, "Version": "SignalA_v1a}`) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + newMockTransport(resp, false, nil) + ua := initTemplate().(*UnitAsset) + // All ok! + gateway = "localhost" + err := ua.sendSetPoint() + if err != nil { + t.Error("Unexpected error:", err) + } + + // Error + gateway = brokenURL + ua.sendSetPoint() + findGateway() + gateway = "localhost" +} + +type testJSON struct { + FirstAttr string `json:"firstAttr"` + Uniqueid string `json:"uniqueid"` + ThirdAttr string `json:"thirdAttr"` +} + +func TestGetConnectedUnits(t *testing.T) { + gateway = "localhost" + // Set up standard response & catch http requests + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: nil, + } + ua := initTemplate().(*UnitAsset) + ua.Uniqueid = "123test" + + // --- Broken body --- + newMockTransport(resp, false, nil) + resp.Body = errReader(0) + err := ua.getConnectedUnits(ua.Model) + + if err == nil { + t.Error("Expected error while unpacking body in getConnectedUnits()") + } + + // --- All ok! --- + // Make a map + fakeBody := make(map[string]testJSON) + test := testJSON{ + FirstAttr: "123", + Uniqueid: "123test", + ThirdAttr: "456", + } + // Insert the JSON into the map with key="1" + fakeBody["1"] = test + // Marshal and create response + jsonBody, _ := json.Marshal(fakeBody) + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(string(jsonBody))), + } + // Start up a newMockTransport to capture HTTP requests before they leave + newMockTransport(resp, false, nil) + // Test function + err = ua.getConnectedUnits(ua.Model) + if err != nil { + t.Error("Expected no errors, error occured:", err) + } + + // --- Bad statuscode --- + resp.StatusCode = 300 + newMockTransport(resp, false, nil) + err = ua.getConnectedUnits(ua.Model) + if err == nil { + t.Errorf("Expected status code > 299 in getConnectedUnits(), got %v", resp.StatusCode) + } + + // --- Missing uniqueid --- + // Make a map + fakeBody = make(map[string]testJSON) + test = testJSON{ + FirstAttr: "123", + Uniqueid: "missing", + ThirdAttr: "456", + } + // Insert the JSON into the map with key="1" + fakeBody["1"] = test + // Marshal and create response + jsonBody, _ = json.Marshal(fakeBody) + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(string(jsonBody))), + } + // 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()") + } + + // --- Unmarshall error --- + resp.Body = io.NopCloser(strings.NewReader(string(jsonBody) + "123")) + newMockTransport(resp, false, nil) + err = ua.getConnectedUnits(ua.Model) + if err == nil { + t.Error("Error expected during unmarshalling, got nil instead", err) + } + + // --- Error performing request --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + err = ua.getConnectedUnits(ua.Model) + if err == nil { + t.Error("Error expected while performing http request, got nil instead") + } +} + +// func createRequest(data string, apiURL string) (req *http.Request, err error) +func TestCreateRequest(t *testing.T) { + data := "test" + apiURL := "http://localhost:8080/test" + + _, err := createRequest(data, apiURL) + if err != nil { + t.Error("Error occured, expected none") + } + + _, err = createRequest(data, brokenURL) + if err == nil { + t.Error("Expected error") + } + +} + +var brokenURL string = string([]byte{0x7f}) + +func TestSendRequest(t *testing.T) { + // Set up standard response & catch http requests + fakeBody := fmt.Sprint(`Test`) + + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + + // 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) + if err != nil { + t.Error("Expected no errors, error occured:", err) + } + + // Break defaultClient.Do() + // --- Error performing request --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + s = fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload + req, _ = createRequest(s, apiURL) + err = sendRequest(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) + + err = sendRequest(req) + + if err == nil { + t.Error("Expected errors, no error occured:") + } + + // Error StatusCode + resp.Body = io.NopCloser(strings.NewReader(fakeBody)) + resp.StatusCode = 300 + newMockTransport(resp, false, nil) + err = sendRequest(req) + if err != errStatusCode { + t.Error("Expected errStatusCode, got", err) + } + +} diff --git a/ZigBeeValve/zigbee_test.go b/ZigBeeValve/zigbee_test.go new file mode 100644 index 0000000..ef08958 --- /dev/null +++ b/ZigBeeValve/zigbee_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSetpt(t *testing.T) { + 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.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() + 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": 20`) + unit := strings.Contains(string(stringBody), `"unit": "Celsius"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statment 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 statment to be true!") + } + + // --- Bad test case: Default part of code (faulty http method) --- + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8670/ZigBee/SmartThermostat1/setpoint", nil) + r.Header.Set("Content-Type", "application/json") + ua.setpt(w, r) + // Read response and check statuscode, expecting 404 (StatusNotFound) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected the status to be bad but got: %v", resp.StatusCode) + } + + // --- Bad PUT (Cant reach ZigBee) --- + w = httptest.NewRecorder() + // Make the body + 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.Header.Set("Content-Type", "application/json") + 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) + } + + // --- Bad test case: PUT Failing @ HTTPProcessSetRequest --- + w = httptest.NewRecorder() + // Make the body + 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.Header.Set("Content-Type", "application/json") + ua.setpt(w, r) + resp = w.Result() + // Check for errors + if resp.StatusCode == good_code { + t.Errorf("Bad PUT: Expected an error during HTTPProcessSetRequest") + } + + // --- Good test case: PUT --- + w = httptest.NewRecorder() + // Make the body and request + 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) + 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() + // 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") + } +} diff --git a/go.mod b/go.mod index f731fe8..2bd2e28 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,6 @@ go 1.23 require github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 +require github.com/coder/websocket v1.8.12 + replace github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 => github.com/lmas/mbaigo v0.0.0-20250123014631-ad869265483c diff --git a/go.sum b/go.sum index 674808d..6ee53cd 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/lmas/mbaigo v0.0.0-20250123014631-ad869265483c h1:W+Jr5GQGKN4BiFOeAc6Uaq/Xc3k4/O5l+XzvsGlnlCQ= github.com/lmas/mbaigo v0.0.0-20250123014631-ad869265483c/go.mod h1:Bfx9Uj0uiTT7BCzzlImMiRd6vMoPQdsZIHGMQOVjx80=