diff --git a/SunButton/SunButton.go b/SunButton/SunButton.go new file mode 100644 index 0000000..8741d2a --- /dev/null +++ b/SunButton/SunButton.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +func main() { + // Prepare for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) // Create a context that can be cancelled + defer cancel() // Make sure all paths cancel the context to avoid context leak + + // Instantiate the System + sys := components.NewSystem("SunButton", ctx) + + // Instantiate the Capsule + sys.Husk = &components.Husk{ + Description: "Is a controller for a consumed button based on a consumed time of day. Powered by SunriseSunset.io", + Certificate: "ABCD", + Details: map[string][]string{"Developer": {"Arrowhead"}}, + ProtoPort: map[string]int{"https": 0, "http": 8670, "coap": 0}, + InfoLink: "https://github.com/lmas/d0020e_code/tree/master/SunButton", + } + + // Instantiate a template unit asset + assetTemplate := initTemplate() + assetName := assetTemplate.GetName() + sys.UAssets[assetName] = &assetTemplate + + // Configure the system + rawResources, servsTemp, err := usecases.Configure(&sys) + if err != nil { + log.Fatalf("Configuration error: %v\n", err) + } + sys.UAssets = make(map[string]*components.UnitAsset) // Clear the unit asset map (from the template) + for _, raw := range rawResources { + var uac UnitAsset + if err := json.Unmarshal(raw, &uac); err != nil { + log.Fatalf("Resource configuration error: %+v\n", err) + } + ua, startup := newUnitAsset(uac, &sys, servsTemp) + startup() + sys.UAssets[ua.GetName()] = &ua + } + + // Generate PKI keys and CSR to obtain a authentication certificate from the CA + usecases.RequestCertificate(&sys) + + // Register the (system) and its services + usecases.RegisterServices(&sys) + + // Start the http handler and server + go usecases.SetoutServers(&sys) + + // Wait for shutdown signal and gracefully close properly goroutines with context + <-sys.Sigs // Wait for a SIGINT (Crtl+C) signal + fmt.Println("\nShutting down system", sys.Name) + cancel() // Cancel the context, signaling the goroutines to stop + time.Sleep(2 * time.Second) // Allow the go routines to be executed, which might take more time then the main routine to end +} + +// Serving handles the resource services. NOTE: It expects those names from the request URL path +func (t *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) { + switch servicePath { + case "ButtonStatus": + t.httpSetButton(w, r) + case "Latitude": + t.httpSetLatitude(w, r) + case "Longitude": + t.httpSetLongitude(w, r) + default: + http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) + } +} + +// All these functions below handles HTTP "PUT" or "GET" requests to modify or retrieve the latitude and longitude and the state of the button +// For the PUT case - the "HTTPProcessSetRequest(w, r)" is called to prosses the data given from the user and if no error, +// call the set functions in thing.go with the value witch updates the value in the struct +func (rsc *UnitAsset) httpSetButton(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setButtonStatus(sig) + case "GET": + signalErr := rsc.getButtonStatus() + usecases.HTTPProcessGetRequest(w, r, &signalErr) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} + +func (rsc *UnitAsset) httpSetLatitude(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setLatitude(sig) + case "GET": + signalErr := rsc.getLatitude() + usecases.HTTPProcessGetRequest(w, r, &signalErr) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} + +func (rsc *UnitAsset) httpSetLongitude(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setLongitude(sig) + case "GET": + signalErr := rsc.getLongitude() + usecases.HTTPProcessGetRequest(w, r, &signalErr) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} diff --git a/SunButton/SunButton_test.go b/SunButton/SunButton_test.go new file mode 100644 index 0000000..8ca33bb --- /dev/null +++ b/SunButton/SunButton_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHttpSetButton(t *testing.T) { + ua := initTemplate().(*UnitAsset) + + // Good case test: GET + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", nil) + goodStatusCode := 200 + ua.httpSetButton(w, r) + // calls the method and extracts the response and save is in resp for the upcoming tests + resp := w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // this is a simple check if the JSON response contains the specific value/unit/version + value := strings.Contains(string(body), `"value": 0.5`) + unit := strings.Contains(string(body), `"unit": "bool"`) + version := strings.Contains(string(body), `"version": "SignalA_v1.0"`) + // check results from above + if value != true { + t.Errorf("expected the statement to be true!") + } + if unit != true { + t.Errorf("expected the unit statement to be true!") + } + if version != true { + t.Errorf("expected the version statement to be true!") + } + + //Godd test case: PUT + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody := bytes.NewReader([]byte(`{"value": 0, "unit": "bool", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", fakebody) // simulating a put request from a user to update the button status + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetButton(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + + //BAD case: PUT, if the fake body is formatted incorrectly + + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody = bytes.NewReader([]byte(`{"123, "unit": "bool", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", fakebody) // simulating a put request from a user to update the button status + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetButton(w, r) + // save the response and read the body + resp = w.Result() + if resp.StatusCode == goodStatusCode { + t.Errorf("expected bad status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + // Bad test case: default part of code + // force the case to hit default statement but alter the method + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", nil) + // calls the method and extracts the response and save is in resp for the upcoming tests + ua.httpSetButton(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetLatitude(t *testing.T) { + ua := initTemplate().(*UnitAsset) + + //Godd test case: PUT + // creates a fake request body with JSON data + w := httptest.NewRecorder() + fakebody := bytes.NewReader([]byte(`{"value": 65.584816, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Latitude", fakebody) // simulating a put request from a user to update the latitude + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + goodStatusCode := 200 + ua.httpSetLatitude(w, r) + + // save the response and read the body + resp := w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + + //BAD case: PUT, if the fake body is formatted incorrectly + + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody = bytes.NewReader([]byte(`{"123, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Latitude", fakebody) // simulating a put request from a user to update the latitude + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetLatitude(w, r) + // save the response and read the body + resp = w.Result() + if resp.StatusCode == goodStatusCode { + t.Errorf("expected bad status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + //Good test case: GET + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://172.30.106.39:8670/SunButton/Button/Latitude", nil) + goodStatusCode = 200 + ua.httpSetLatitude(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // this is a simple check if the JSON response contains the specific value/unit/version + value := strings.Contains(string(body), `"value": 65.584816`) + unit := strings.Contains(string(body), `"unit": "Degrees"`) + version := strings.Contains(string(body), `"version": "SignalA_v1.0"`) + // check the result from above + if value != true { + t.Errorf("expected the statement to be true!") + } + if unit != true { + t.Errorf("expected the unit statement to be true!") + } + if version != true { + t.Errorf("expected the version statement to be true!") + } + // bad test case: default part of code + // force the case to hit default statement but alter the method + w = httptest.NewRecorder() + r = httptest.NewRequest("666", "http://172.30.106.39:8670/SunButton/Button/Latitude", nil) + ua.httpSetLatitude(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetLongitude(t *testing.T) { + ua := initTemplate().(*UnitAsset) + //Godd test case: PUT + + // creates a fake request body with JSON data + w := httptest.NewRecorder() + fakebody := bytes.NewReader([]byte(`{"value": 22.156704, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Longitude", fakebody) // simulating a put request from a user to update the longitude + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + goodStatusCode := 200 + ua.httpSetLongitude(w, r) + + // save the response and read the body + resp := w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + //BAD case: PUT, if the fake body is formatted incorrectly + + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody = bytes.NewReader([]byte(`{"123, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Longitude", fakebody) // simulating a put request from a user to update the min temp + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetLongitude(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode == goodStatusCode { + t.Errorf("expected bad status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + //Good test case: GET + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://172.30.106.39:8670/SunButton/Button/Longitude", nil) + goodStatusCode = 200 + ua.httpSetLongitude(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // this is a simple check if the JSON response contains the specific value/unit/version + value := strings.Contains(string(body), `"value": 22.156704`) + unit := strings.Contains(string(body), `"unit": "Degrees"`) + version := strings.Contains(string(body), `"version": "SignalA_v1.0"`) + if value != true { + t.Errorf("expected the statement to be true!") + } + if unit != true { + t.Errorf("expected the unit statement to be true!") + } + if version != true { + t.Errorf("expected the version statement to be true!") + } + // bad test case: default part of code + + // force the case to hit default statement but alter the method + w = httptest.NewRecorder() + r = httptest.NewRequest("666", "http://172.30.106.39:8670/SunButton/Button/Longitude", nil) + ua.httpSetLongitude(w, r) + //save the response + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} diff --git a/SunButton/thing.go b/SunButton/thing.go new file mode 100644 index 0000000..04e9831 --- /dev/null +++ b/SunButton/thing.go @@ -0,0 +1,325 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/forms" + "github.com/sdoque/mbaigo/usecases" +) + +type SunData struct { + Date string `json:"date"` + Sunrise string `json:"sunrise"` + Sunset string `json:"sunset"` + First_light string `json:"first_light"` + Last_light string `json:"last_light"` + Dawn string `json:"dawn"` + Dusk string `json:"dusk"` + Solar_noon string `json:"solar_noon"` + Golden_hour string `json:"golden_hour"` + Day_length string `json:"day_length"` + Timezone string `json:"timezone"` + Utc_offset float64 `json:"utc_offset"` +} + +type Data struct { + Results SunData `json:"results"` + Status string `json:"status"` +} + +// A unitAsset models an interface or API for a smaller part of a whole system, for example a single temperature sensor. +// This type must implement the go interface of "components.UnitAsset" +type UnitAsset struct { + Name string `json:"name"` + Owner *components.System `json:"-"` + Details map[string][]string `json:"details"` + ServicesMap components.Services `json:"-"` + CervicesMap components.Cervices `json:"-"` + + Period time.Duration `json:"samplingPeriod"` + + ButtonStatus float64 `json:"ButtonStatus"` + Latitude float64 `json:"Latitude"` + oldLatitude float64 + Longitude float64 `json:"Longitude"` + oldLongitude float64 + data Data +} + +// GetName returns the name of the Resource. +func (ua *UnitAsset) GetName() string { + return ua.Name +} + +// GetServices returns the services of the Resource. +func (ua *UnitAsset) GetServices() components.Services { + return ua.ServicesMap +} + +// GetCervices returns the list of consumed services by the Resource. +func (ua *UnitAsset) GetCervices() components.Cervices { + return ua.CervicesMap +} + +// GetDetails returns the details of the Resource. +func (ua *UnitAsset) GetDetails() map[string][]string { + return ua.Details +} + +// Ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation) +var _ components.UnitAsset = (*UnitAsset)(nil) + +//////////////////////////////////////////////////////////////////////////////// + +// initTemplate initializes a new UA and prefils it with some default values. +// The returned instance is used for generating the configuration file, whenever it's missing. +// (see https://github.com/sdoque/mbaigo/blob/main/components/service.go for documentation) +func initTemplate() components.UnitAsset { + setLatitude := components.Service{ + Definition: "Latitude", + SubPath: "Latitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current set latitude (using a GET request)", + } + setLongitude := components.Service{ + Definition: "Longitude", + SubPath: "Longitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current set longitude (using a GET request)", + } + setButtonStatus := components.Service{ + Definition: "ButtonStatus", + SubPath: "ButtonStatus", + Details: map[string][]string{"Unit": {"bool"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the status of a button (using a GET request)", + } + + return &UnitAsset{ + // These fields should reflect a unique asset (ie, a single sensor with unique ID and location) + Name: "Button", + Details: map[string][]string{"Location": {"Kitchen"}}, + Latitude: 65.584816, // Latitude for the button + Longitude: 22.156704, // Longitude for the button + ButtonStatus: 0.5, // Status for the button (on/off) NOTE: This status is neither on or off as default, this is up for the system to decide. + Period: 15, + data: Data{SunData{}, ""}, + + // Maps the provided services from above + ServicesMap: components.Services{ + setLatitude.SubPath: &setLatitude, + setLongitude.SubPath: &setLongitude, + setButtonStatus.SubPath: &setButtonStatus, + }, + } +} + +//////////////////////////////////////////////////////////////////////////////// + +// newUnitAsset creates a new and proper instance of UnitAsset, using settings and +// values loaded from an existing configuration file. +// This function returns an UA instance that is ready to be published and used, +// aswell as a function that can perform any cleanup when the system is shutting down. +func newUnitAsset(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) { + sProtocol := components.SProtocols(sys.Husk.ProtoPort) + + // the Cervice that is to be consumed by the ZigBee, therefore the name with the C + t := &components.Cervice{ + Name: "state", + Protos: sProtocol, + Url: make([]string, 0), + } + + ua := &UnitAsset{ + // Filling in public fields using the given data + Name: uac.Name, + Owner: sys, + Details: uac.Details, + ServicesMap: components.CloneServices(servs), + Latitude: uac.Latitude, + Longitude: uac.Longitude, + ButtonStatus: uac.ButtonStatus, + Period: uac.Period, + data: uac.data, + CervicesMap: components.Cervices{ + t.Name: t, + }, + } + + var ref components.Service + for _, s := range servs { + if s.Definition == "ButtonStatus" { + ref = s + } + } + + ua.CervicesMap["state"].Details = components.MergeDetails(ua.Details, ref.Details) + + // Returns the loaded unit asset and a function to handle + return ua, func() { + // Start the unit asset(s) + go ua.feedbackLoop(sys.Ctx) + } +} + +// getLatitude is used for reading the current latitude +func (ua *UnitAsset) getLatitude() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.Latitude + f.Unit = "Degrees" + f.Timestamp = time.Now() + return f +} + +// setLatitude is used for updating the current latitude +func (ua *UnitAsset) setLatitude(f forms.SignalA_v1a) { + ua.oldLatitude = ua.Latitude + ua.Latitude = f.Value +} + +// getLongitude is used for reading the current longitude +func (ua *UnitAsset) getLongitude() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.Longitude + f.Unit = "Degrees" + f.Timestamp = time.Now() + return f +} + +// setLongitude is used for updating the current longitude +func (ua *UnitAsset) setLongitude(f forms.SignalA_v1a) { + ua.oldLongitude = ua.Longitude + ua.Longitude = f.Value +} + +// getButtonStatus is used for reading the current button status +func (ua *UnitAsset) getButtonStatus() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.ButtonStatus + f.Unit = "bool" + f.Timestamp = time.Now() + return f +} + +// setButtonStatus is used for updating the current button status +func (ua *UnitAsset) setButtonStatus(f forms.SignalA_v1a) { + ua.ButtonStatus = f.Value +} + +// feedbackLoop is THE control loop (IPR of the system) +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 { + case <-ticker.C: + ua.processFeedbackLoop() + case <-ctx.Done(): + return + } + } +} + +// This function sends a new button status to the ZigBee system if needed +func (ua *UnitAsset) processFeedbackLoop() { + date := time.Now().Format("2006-01-02") // Gets the current date in the defined format. + apiURL := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + + if !((ua.data.Results.Date == date) && ((ua.oldLatitude == ua.Latitude) && (ua.oldLongitude == ua.Longitude))) { // If there is a new day or latitude or longitude is changed new data is downloaded. + log.Printf("Sun API has not been called today for this region, downloading sun data...") + err := ua.getAPIData(apiURL) + if err != nil { + log.Printf("Cannot get sun API data: %s\n", err) + return + } + } + layout := "15:04:05" + sunrise, _ := time.Parse(layout, ua.data.Results.Sunrise) // Saves the sunrise in the layout format. + sunset, _ := time.Parse(layout, ua.data.Results.Sunset) // Saves the sunset in the layout format. + currentTime, _ := time.Parse(layout, time.Now().Local().Format("15:04:05")) // Saves the current time in the layout format. + if currentTime.After(sunrise) && !(currentTime.After(sunset)) { // This checks if the time is between sunrise or sunset, if it is the switch is supposed to turn off. + if ua.ButtonStatus == 0 { // If the button is already off there is no need to send a state again. + log.Printf("The button is already off") + return + } + ua.ButtonStatus = 0 + err := ua.sendStatus() + if err != nil { + return + } + + } else { // If the time is not between sunrise and sunset the button is supposed to be on. + if ua.ButtonStatus == 1 { // If the button is already on there is no need to send a state again. + log.Printf("The button is already on") + return + } + ua.ButtonStatus = 1 + err := ua.sendStatus() + if err != nil { + return + } + } +} + +func (ua *UnitAsset) sendStatus() error { + // Prepare the form to send + var of forms.SignalA_v1a + of.NewForm() + of.Value = ua.ButtonStatus + of.Unit = ua.CervicesMap["state"].Details["Unit"][0] + of.Timestamp = time.Now() + // Pack the new state form + // Pack() converting the data in "of" into JSON format + op, err := usecases.Pack(&of, "application/json") + if err != nil { + return err + } + // Send the new request + err = usecases.SetState(ua.CervicesMap["state"], ua.Owner, op) + if err != nil { + log.Printf("Cannot update ZigBee state: %s\n", err) + return err + } + return nil +} + +var errStatuscode error = fmt.Errorf("bad status code") + +func (ua *UnitAsset) getAPIData(apiURL string) error { + //apiURL := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + parsedURL, err := url.Parse(apiURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return errors.New("the url is invalid") + } + // End of validating the URL // + res, err := http.Get(parsedURL.String()) + if err != nil { + return err + } + body, err := io.ReadAll(res.Body) // Read the payload into body variable + if err != nil { + return err + } + err = json.Unmarshal(body, &ua.data) + + defer res.Body.Close() + + if res.StatusCode > 299 { + return errStatuscode + } + if err != nil { + return err + } + return nil +} diff --git a/SunButton/thing_test.go b/SunButton/thing_test.go new file mode 100644 index 0000000..ee152d9 --- /dev/null +++ b/SunButton/thing_test.go @@ -0,0 +1,340 @@ +package main + +import ( + "context" + "fmt" + "io" + "strings" + + //"io" + "net/http" + //"strings" + "testing" + "time" + + "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 { + resp *http.Response + hits map[string]int +} + +func newMockTransport(resp *http.Response) mockTransport { + t := mockTransport{ + resp: resp, + hits: make(map[string]int), + } + // Hijack the default http client so no actual http requests are sent over the network + http.DefaultClient.Transport = t + return t +} + +// domainHits returns the number of requests to a domain (or -1 if domain wasn't found). +func (t mockTransport) domainHits(domain string) int { + for u, hits := range t.hits { + if u == domain { + return hits + } + } + return -1 +} + +var sunDataExample string = fmt.Sprintf(`[{ + "results": { + "date": "%d-%02d-%02d", + "sunrise": "08:00:00", + "sunset": "20:00:00", + "first_light": "07:00:00", + "last_light": "21:00:00", + "dawn": "07:30:00", + "dusk": "20:30:00", + "solar_noon": "16:00:00", + "golden_hour": "19:00:00", + "day_length": "12:00:00" + "timezone": "CET", + "utc_offset": "1" + }, + "status": "OK" +}]`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + +// 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. +func (t mockTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + t.hits[req.URL.Hostname()] += 1 + t.resp.Request = req + return t.resp, nil +} + +// ////////////////////////////////////////////////////////////////////////////// +const apiDomain string = "https://sunrisesunset.io/" + +func TestSingleUnitAssetOneAPICall(t *testing.T) { + ua := initTemplate().(*UnitAsset) + //maby better to use a getter method + url := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + //Body: io.NopCloser(strings.NewReader(fakeBody)), + } + trans := newMockTransport(resp) + // Creates a single UnitAsset and assert it only sends a single API request + + //retrieveAPIPrice(ua) + // better to use a getter method?? + ua.getAPIData(url) + + // TEST CASE: cause a single API request + hits := trans.domainHits(apiDomain) + if hits > 1 { + t.Errorf("expected number of api requests = 1, got %d requests", hits) + } +} + +func TestSetMethods(t *testing.T) { + asset := initTemplate().(*UnitAsset) + + // Simulate the input signals + buttonStatus := forms.SignalA_v1a{ + Value: 0, + } + //call and test ButtonStatus + asset.setButtonStatus(buttonStatus) + if asset.ButtonStatus != 0 { + t.Errorf("expected ButtonStatus to be 0, got %f", asset.ButtonStatus) + } + // Simulate the input signals + latitude := forms.SignalA_v1a{ + Value: 65.584816, + } + // call and test Latitude + asset.setLatitude(latitude) + if asset.Latitude != 65.584816 { + t.Errorf("expected Latitude to be 65.584816, got %f", asset.Latitude) + } + // Simulate the input signals + longitude := forms.SignalA_v1a{ + Value: 22.156704, + } + //call and test MinPrice + asset.setLongitude(longitude) + if asset.Longitude != 22.156704 { + t.Errorf("expected Longitude to be 22.156704, got %f", asset.Longitude) + } +} + +func TestGetMethods(t *testing.T) { + uasset := initTemplate().(*UnitAsset) + + // ButtonStatus + // check if the value from the struct is the actual value that the func is getting + result1 := uasset.getButtonStatus() + if result1.Value != uasset.ButtonStatus { + t.Errorf("expected Value of the ButtonStatus is to be %v, got %v", uasset.ButtonStatus, result1.Value) + } + //check that the Unit is correct + if result1.Unit != "bool" { + t.Errorf("expected Unit to be 'bool', got %v", result1.Unit) + } + // Latitude + // check if the value from the struct is the actual value that the func is getting + result2 := uasset.getLatitude() + if result2.Value != uasset.Latitude { + t.Errorf("expected Value of the Latitude is to be %v, got %v", uasset.Latitude, result2.Value) + } + //check that the Unit is correct + if result2.Unit != "Degrees" { + t.Errorf("expected Unit to be 'Degrees', got %v", result2.Unit) + } + // Longitude + // check if the value from the struct is the actual value that the func is getting + result3 := uasset.getLongitude() + if result3.Value != uasset.Longitude { + t.Errorf("expected Value of the Longitude is to be %v, got %v", uasset.Longitude, result3.Value) + } + //check that the Unit is correct + if result3.Unit != "Degrees" { + t.Errorf("expected Unit to be 'Degrees', got %v", result3.Unit) + } +} + +func TestInitTemplate(t *testing.T) { + uasset := initTemplate().(*UnitAsset) + + //// unnecessary test, but good for practicing + name := uasset.GetName() + if name != "Button" { + t.Errorf("expected name of the resource is %v, got %v", uasset.Name, name) + } + Services := uasset.GetServices() + if Services == nil { + t.Fatalf("If Services is nil, not worth to continue testing") + } + // Services + if Services["ButtonStatus"].Definition != "ButtonStatus" { + t.Errorf("expected service definition to be ButtonStatus") + } + if Services["Latitude"].Definition != "Latitude" { + t.Errorf("expected service definition to be Latitude") + } + if Services["Longitude"].Definition != "Longitude" { + t.Errorf("expected service definition to be Longitude") + } + //GetCervice// + Cervices := uasset.GetCervices() + if Cervices != nil { + t.Fatalf("If cervises not nil, not worth to continue testing") + } + //Testing Details// + Details := uasset.GetDetails() + if Details == nil { + t.Errorf("expected a map, but Details was nil, ") + } +} + +func TestNewUnitAsset(t *testing.T) { + // prepare for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled + defer cancel() // make sure all paths cancel the context to avoid context leak + // instantiate the System + sys := components.NewSystem("SunButton", ctx) + // Instantiate the Capsule + sys.Husk = &components.Husk{ + Description: " is a controller for a consumed smart plug based on status depending on the sun", + Certificate: "ABCD", + Details: map[string][]string{"Developer": {"Arrowhead"}}, + ProtoPort: map[string]int{"https": 0, "http": 8670, "coap": 0}, + InfoLink: "https://github.com/lmas/d0020e_code/tree/Comfortstat/SunButton", + } + setButtonStatus := components.Service{ + Definition: "ButtonStatus", + SubPath: "ButtonStatus", + Details: map[string][]string{"Unit": {"bool"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current button status (using a GET request)", + } + setLatitude := components.Service{ + Definition: "Latitude", + SubPath: "Latitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the latitude (using a GET request)", + } + setLongitude := components.Service{ + Definition: "Longitude", + SubPath: "Longitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the longitude (using a GET request)", + } + // New UnitAsset struct init + uac := UnitAsset{ + //These fields should reflect a unique asset (ie, a single sensor with unique ID and location) + Name: "Button", + Details: map[string][]string{"Location": {"Kitchen"}}, + ButtonStatus: 0.5, + Latitude: 65.584816, + Longitude: 22.156704, + Period: 15, + data: Data{SunData{}, ""}, + + // Maps the provided services from above + ServicesMap: components.Services{ + setButtonStatus.SubPath: &setButtonStatus, + setLatitude.SubPath: &setLatitude, + setLongitude.SubPath: &setLongitude, + }, + } + + ua, _ := newUnitAsset(uac, &sys, nil) + // Calls the method that gets the name of the new UnitAsset + name := ua.GetName() + if name != "Button" { + t.Errorf("expected name to be Button, but got: %v", name) + } +} + +// Functions that help creating bad body +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 +} + +// cretas a URL that is broken +var brokenURL string = string([]byte{0x7f}) + +func TestGetAPIPriceDataSun(t *testing.T) { + ua := initTemplate().(*UnitAsset) + // Should not be an array, it should match the exact struct + sunDataExample = fmt.Sprintf(`{ + "results": { + "date": "%d-%02d-%02d", + "sunrise": "08:00:00", + "sunset": "20:00:00", + "first_light": "07:00:00", + "last_light": "21:00:00", + "dawn": "07:30:00", + "dusk": "20:30:00", + "solar_noon": "16:00:00", + "golden_hour": "19:00:00", + "day_length": "12:00:00", + "timezone": "CET", + "utc_offset": 1 + }, + "status": "OK" + }`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day(), + ) + // creates a fake response + fakeBody := fmt.Sprintf(sunDataExample) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + // Testing good cases + // Test case: goal is no errors + apiURL := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + fmt.Println("API URL:", apiURL) + + // creates a mock HTTP transport to simulate api response for the test + newMockTransport(resp) + err := ua.getAPIData(apiURL) + if err != nil { + t.Errorf("expected no errors but got %s :", err) + } + // Testing bad cases + // Test case: using wrong url leads to an error + newMockTransport(resp) + // Call the function (which now hits the mock server) + err = ua.getAPIData(brokenURL) + if err == nil { + t.Errorf("Expected an error but got none!") + } + // Test case: if reading the body causes an error + resp.Body = errReader(0) + newMockTransport(resp) + err = ua.getAPIData(apiURL) + if err != errBodyRead { + t.Errorf("expected an error %v, got %v", errBodyRead, err) + } + //Test case: if status code > 299 + resp.Body = io.NopCloser(strings.NewReader(fakeBody)) + resp.StatusCode = 300 + newMockTransport(resp) + err = ua.getAPIData(apiURL) + // check the statuscode is bad, witch is expected for the test to be successful + if err != errStatuscode { + t.Errorf("expected an bad status code but got %v", err) + } +}