diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1d0f615..d95f148 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,6 +28,8 @@ jobs: uses: actions/setup-go@v5 with: go-version: 1.23 + - name: Install dependencies + run: make deps - name: Run tests run: make test - name: Report stats diff --git a/Comfortstat/Comfortstat.go b/Comfortstat/Comfortstat.go deleted file mode 100644 index cd2480c..0000000 --- a/Comfortstat/Comfortstat.go +++ /dev/null @@ -1,184 +0,0 @@ -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("Comfortstat", ctx) - - // Instatiate the Capusle - sys.Husk = &components.Husk{ - Description: " is a controller for a consumed servo motor position based on a consumed temperature", - 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/Comfortstat", - } - - // 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, cleanup := newUnitAsset(uac, &sys, servsTemp) - defer cleanup() - 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 (Ctrl+C) signal - fmt.Println("\nshuting 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 than the main routine to end -} - -// TODO: change the namne, will get one function for each of the four cases -// Serving handles the resources services. NOTE: it exepcts those names from the request URL path -func (t *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) { - switch servicePath { - case "min_temperature": - t.set_minTemp(w, r) - case "max_temperature": - t.set_maxTemp(w, r) - case "max_price": - t.set_maxPrice(w, r) - case "min_price": - t.set_minPrice(w, r) - case "SEK_price": - t.set_SEKprice(w, r) - case "desired_temp": - t.set_desiredTemp(w, r) - default: - http.Error(w, "Invalid service request [Do not modify the services subpath in the configurration file]", http.StatusBadRequest) - } -} - -func (rsc *UnitAsset) set_SEKprice(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "GET": - signalErr := rsc.getSEK_price() - usecases.HTTPProcessGetRequest(w, r, &signalErr) - default: - http.Error(w, "Method is not supported.", http.StatusNotFound) - } -} - -// TODO: split up this function to two sepreate function that sets on max and min price. -func (rsc *UnitAsset) set_minTemp(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PUT": - sig, err := usecases.HTTPProcessSetRequest(w, r) - if err != nil { - log.Println("Error with the setting request of the position ", err) - } - rsc.setMin_temp(sig) - case "GET": - signalErr := rsc.getMin_temp() - usecases.HTTPProcessGetRequest(w, r, &signalErr) - default: - http.Error(w, "Method is not supported.", http.StatusNotFound) - } -} -func (rsc *UnitAsset) set_maxTemp(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PUT": - sig, err := usecases.HTTPProcessSetRequest(w, r) - if err != nil { - log.Println("Error with the setting request of the position ", err) - } - rsc.setMax_temp(sig) - case "GET": - signalErr := rsc.getMax_temp() - usecases.HTTPProcessGetRequest(w, r, &signalErr) - default: - http.Error(w, "Method is not supported.", http.StatusNotFound) - } -} - -// LOOK AT: I guess that we probable only need to if there is a PUT from user? -// LOOK AT: so not the GET! -// For PUT - the "HTTPProcessSetRequest(w, r)" is called to prosses the data given from the user and if no error, call set_minMaxprice with the value -// wich updates the value in thge struct -func (rsc *UnitAsset) set_minPrice(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PUT": - sig, err := usecases.HTTPProcessSetRequest(w, r) - if err != nil { - log.Println("Error with the setting request of the position ", err) - } - rsc.setMin_price(sig) - case "GET": - signalErr := rsc.getMin_price() - usecases.HTTPProcessGetRequest(w, r, &signalErr) - default: - http.Error(w, "Method is not supported.", http.StatusNotFound) - - } -} - -func (rsc *UnitAsset) set_maxPrice(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PUT": - sig, err := usecases.HTTPProcessSetRequest(w, r) - if err != nil { - log.Println("Error with the setting request of the position ", err) - } - rsc.setMax_price(sig) - case "GET": - signalErr := rsc.getMax_price() - usecases.HTTPProcessGetRequest(w, r, &signalErr) - default: - http.Error(w, "Method is not supported.", http.StatusNotFound) - - } -} - -func (rsc *UnitAsset) set_desiredTemp(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PUT": - sig, err := usecases.HTTPProcessSetRequest(w, r) - if err != nil { - log.Println("Error with the setting request of the position ", err) - } - rsc.setDesired_temp(sig) - case "GET": - signalErr := rsc.getDesired_temp() - usecases.HTTPProcessGetRequest(w, r, &signalErr) - default: - http.Error(w, "Method is not supported.", http.StatusNotFound) - } -} diff --git a/Comfortstat/api_fetch_test.go b/Comfortstat/api_fetch_test.go deleted file mode 100644 index 238e1d9..0000000 --- a/Comfortstat/api_fetch_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/http" - "strings" - "testing" - "time" -) - -// mockTransport is used for replacing the default network Transport (used by -// http.DefaultClient) and it will intercept network requests. -type mockTransport struct { - hits map[string]int -} - -func newMockTransport() mockTransport { - t := mockTransport{ - hits: make(map[string]int), - } - // Highjack the default http client so no actuall 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 -} - -// TODO: this might need to be expanded to a full JSON array? -const priceExample string = `[{ - "SEK_per_kWh": 0.26673, - "EUR_per_kWh": 0.02328, - "EXR": 11.457574, - "time_start": "2025-01-06T%02d:00:00+01:00", - "time_end": "2025-01-06T%02d:00:00+01:00" -}]` - -// 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) { - hour := time.Now().Local().Hour() - fakeBody := fmt.Sprintf(priceExample, hour, hour+1) - // TODO: should be able to adjust these return values for the error cases - resp = &http.Response{ - Status: "200 OK", - StatusCode: 200, - Request: req, - Body: io.NopCloser(strings.NewReader(fakeBody)), - } - t.hits[req.URL.Hostname()] += 1 - return -} - -//////////////////////////////////////////////////////////////////////////////// - -const apiDomain string = "www.elprisetjustnu.se" - -func TestAPIDataFetchPeriod(t *testing.T) { - want := 3600 - if apiFetchPeriod < want { - t.Errorf("expected API fetch period >= %d, got %d", want, apiFetchPeriod) - } -} - -func TestSingleUnitAssetOneAPICall(t *testing.T) { - trans := newMockTransport() - // Creates a single UnitAsset and assert it only sends a single API request - ua := initTemplate().(*UnitAsset) - retrieveAPI_price(ua) - - // 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) - } - - // TODO: try more test cases! -} - -func TestMultipleUnitAssetOneAPICall(t *testing.T) { - trans := newMockTransport() - // Creates multiple UnitAssets and monitor their API requests - units := 10 - for i := 0; i < units; i++ { - ua := initTemplate().(*UnitAsset) - retrieveAPI_price(ua) - } - - // TEST CASE: causing only one API hit while using multiple UnitAssets - hits := trans.domainHits(apiDomain) - if hits > 1 { - t.Errorf("expected number of api requests = 1, got %d requests (from %d units)", hits, units) - } - - // TODO: more test cases?? -} diff --git a/Comfortstat/things.go b/Comfortstat/things.go deleted file mode 100644 index 711e710..0000000 --- a/Comfortstat/things.go +++ /dev/null @@ -1,458 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "math" - "net/http" - "time" - - "github.com/sdoque/mbaigo/components" - "github.com/sdoque/mbaigo/forms" - "github.com/sdoque/mbaigo/usecases" -) - -// 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 { - // Public fields - // TODO: Why have these public and then provide getter methods? Might need refactor.. - Name string `json:"name"` // Must be a unique name, ie. a sensor ID - Owner *components.System `json:"-"` // The parent system this UA is part of - Details map[string][]string `json:"details"` // Metadata or details about this UA - ServicesMap components.Services `json:"-"` - CervicesMap components.Cervices `json:"-"` - // - Period time.Duration `json:"samplingPeriod"` - // - Daily_prices []API_data `json:"-"` - Desired_temp float64 `json:"desired_temp"` - old_desired_temp float64 // keep this field private! - SEK_price float64 `json:"SEK_per_kWh"` - Min_price float64 `json:"min_price"` - Max_price float64 `json:"max_price"` - Min_temp float64 `json:"min_temp"` - Max_temp float64 `json:"max_temp"` -} - -type API_data struct { - SEK_price float64 `json:"SEK_per_kWh"` - EUR_price float64 `json:"EUR_per_kWh"` - EXR float64 `json:"EXR"` - Time_start string `json:"time_start"` - Time_end string `json:"time_end"` -} - -// 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. -func initTemplate() components.UnitAsset { - // First predefine any exposed services - // (see https://github.com/sdoque/mbaigo/blob/main/components/service.go for documentation) - setSEK_price := components.Service{ - Definition: "SEK_price", - SubPath: "SEK_price", - Details: map[string][]string{"Unit": {"SEK"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the current electric hourly price (using a GET request)", - } - - setMax_temp := components.Service{ - Definition: "max_temperature", // TODO: this get's incorrectly linked to the below subpath - SubPath: "max_temperature", // TODO: this path needs to be setup in Serving() too - Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, // TODO: why this form here?? - Description: "provides the maximum temp the user wants (using a GET request)", - } - setMin_temp := components.Service{ - Definition: "min_temperature", - SubPath: "min_temperature", - Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the minimum temp the user could tolerate (using a GET request)", - } - setMax_price := components.Service{ - Definition: "max_price", - SubPath: "max_price", - Details: map[string][]string{"Unit": {"SEK"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the maximum price the user wants to pay (using a GET request)", - } - setMin_price := components.Service{ - Definition: "min_price", - SubPath: "min_price", - Details: map[string][]string{"Unit": {"SEK"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the minimum price the user wants to pay (using a GET request)", - } - setDesired_temp := components.Service{ - Definition: "desired_temp", - SubPath: "desired_temp", - Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the desired temperature the system calculates based on user inputs (using a GET request)", - } - - return &UnitAsset{ - // TODO: These fields should reflect a unique asset (ie, a single sensor with unique ID and location) - Name: "Set Values", - Details: map[string][]string{"Location": {"Kitchen"}}, - SEK_price: 7.5, // Example electricity price in SEK per kWh - Min_price: 0.0, // Minimum price allowed - Max_price: 0.02, // Maximum price allowed - Min_temp: 20.0, // Minimum temperature - Max_temp: 25.0, // Maximum temprature allowed - Desired_temp: 0, // Desired temp calculated by system - Period: 15, - - // Don't forget to map the provided services from above! - ServicesMap: components.Services{ - setMax_temp.SubPath: &setMax_temp, - setMin_temp.SubPath: &setMin_temp, - setMax_price.SubPath: &setMax_price, - setMin_price.SubPath: &setMin_price, - setSEK_price.SubPath: &setSEK_price, - setDesired_temp.SubPath: &setDesired_temp, - }, - } -} - -//////////////////////////////////////////////////////////////////////////////// - -// 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 zigbee, there fore the name with the C - - t := &components.Cervice{ - Name: "setpoint", - 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), - SEK_price: uac.SEK_price, - Min_price: uac.Min_price, - Max_price: uac.Max_price, - Min_temp: uac.Min_temp, - Max_temp: uac.Max_temp, - Desired_temp: uac.Desired_temp, - Period: uac.Period, - CervicesMap: components.Cervices{ - t.Name: t, - }, - } - - var ref components.Service - for _, s := range servs { - if s.Definition == "desired_temp" { - ref = s - } - } - - ua.CervicesMap["setpoint"].Details = components.MergeDetails(ua.Details, ref.Details) - - // ua.CervicesMap["setPoint"].Details = components.MergeDetails(ua.Details, map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}) - - // start the unit asset(s) - go ua.feedbackLoop(sys.Ctx) - go ua.API_feedbackLoop(sys.Ctx) - - // Optionally start background tasks here! Example: - go func() { - log.Println("Starting up " + ua.Name) - }() - - // Returns the loaded unit asset and an function to handle optional cleanup at shutdown - return ua, func() { - log.Println("Cleaning up " + ua.Name) - } -} - -// getSEK_price is used for reading the current hourly electric price -func (ua *UnitAsset) getSEK_price() (f forms.SignalA_v1a) { - f.NewForm() - f.Value = ua.SEK_price - f.Unit = "SEK" - f.Timestamp = time.Now() - return f -} - -// setSEK_price updates the current electric price with the new current electric hourly price -func (ua *UnitAsset) setSEK_price(f forms.SignalA_v1a) { - ua.SEK_price = f.Value - log.Printf("new electric price: %.1f", f.Value) -} - -///////////////////////////////////////////////////////////////////////// -///////////////////////////////////////////////////////////////////////// - -// getMin_price is used for reading the current value of Min_price -func (ua *UnitAsset) getMin_price() (f forms.SignalA_v1a) { - f.NewForm() - f.Value = ua.Min_price - f.Unit = "SEK" - f.Timestamp = time.Now() - return f -} - -// setMin_price updates the current minimum price set by the user with a new value -func (ua *UnitAsset) setMin_price(f forms.SignalA_v1a) { - ua.Min_price = f.Value - log.Printf("new minimum price: %.1f", f.Value) - ua.processFeedbackLoop() -} - -// getMax_price is used for reading the current value of Max_price -func (ua *UnitAsset) getMax_price() (f forms.SignalA_v1a) { - f.NewForm() - f.Value = ua.Max_price - f.Unit = "SEK" - f.Timestamp = time.Now() - return f -} - -// setMax_price updates the current minimum price set by the user with a new value -func (ua *UnitAsset) setMax_price(f forms.SignalA_v1a) { - ua.Max_price = f.Value - log.Printf("new maximum price: %.1f", f.Value) - ua.processFeedbackLoop() -} - -// getMin_temp is used for reading the current minimum temerature value -func (ua *UnitAsset) getMin_temp() (f forms.SignalA_v1a) { - f.NewForm() - f.Value = ua.Min_temp - f.Unit = "Celsius" - f.Timestamp = time.Now() - return f -} - -// setMin_temp updates the current minimum temperature set by the user with a new value -func (ua *UnitAsset) setMin_temp(f forms.SignalA_v1a) { - ua.Min_temp = f.Value - log.Printf("new minimum temperature: %.1f", f.Value) - ua.processFeedbackLoop() -} - -// getMax_temp is used for reading the current value of Min_price -func (ua *UnitAsset) getMax_temp() (f forms.SignalA_v1a) { - f.NewForm() - f.Value = ua.Max_temp - f.Unit = "Celsius" - f.Timestamp = time.Now() - return f -} - -// setMax_temp updates the current minimum price set by the user with a new value -func (ua *UnitAsset) setMax_temp(f forms.SignalA_v1a) { - ua.Max_temp = f.Value - log.Printf("new maximum temperature: %.1f", f.Value) - ua.processFeedbackLoop() -} - -func (ua *UnitAsset) getDesired_temp() (f forms.SignalA_v1a) { - f.NewForm() - f.Value = ua.Desired_temp - f.Unit = "Celsius" - f.Timestamp = time.Now() - return f -} - -func (ua *UnitAsset) setDesired_temp(f forms.SignalA_v1a) { - ua.Desired_temp = f.Value - log.Printf("new desired temperature: %.1f", f.Value) -} - -//TODO: This fuction is used for checking the electric price ones every x hours and so on -//TODO: Needs to be modified to match our needs, not using processFeedbacklopp -//TODO: So mayby the period is every hour, call the api to receive the current price ( could be every 24 hours) -//TODO: This function is may be better in the COMFORTSTAT MAIN - -// It's _strongly_ encouraged to not send requests to the API for more than once per hour. -// Making this period a private constant prevents a user from changing this value -// in the config file. -const apiFetchPeriod int = 3600 - -// feedbackLoop is THE control loop (IPR of the system) -func (ua *UnitAsset) API_feedbackLoop(ctx context.Context) { - // Initialize a ticker for periodic execution - ticker := time.NewTicker(time.Duration(apiFetchPeriod) * time.Second) - defer ticker.Stop() - - // start the control loop - for { - retrieveAPI_price(ua) - select { - case <-ticker.C: - // Block the loop until the next period - case <-ctx.Done(): - return - } - } -} - -func retrieveAPI_price(ua *UnitAsset) { - url := fmt.Sprintf(`https://www.elprisetjustnu.se/api/v1/prices/%d/%02d-%02d_SE1.json`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) - log.Println("URL:", url) - - res, err := http.Get(url) - if err != nil { - log.Println("Couldn't get the url, error:", err) - return - } - 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 data []API_data // Create a list to hold the gateway json - err = json.Unmarshal(body, &data) // "unpack" body from []byte to []discoverJSON, save errors - res.Body.Close() // defer res.Body.Close() - - if res.StatusCode > 299 { - log.Printf("Response failed with status code: %d and\nbody: %s\n", res.StatusCode, body) - return - } - if err != nil { - log.Println("Error during Unmarshal, error:", err) - return - } - - ///////// - now := fmt.Sprintf(`%d-%02d-%02dT%02d:00:00+01:00`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day(), time.Now().Local().Hour()) - for _, i := range data { - if i.Time_start == now { - ua.SEK_price = i.SEK_price - log.Println("Price in loop is:", i.SEK_price) - } - - } - log.Println("current el-pris is:", ua.SEK_price) - - // Don't send temperature updates if the difference is too low - // (this could potentially save on battery!) - new_temp := ua.calculateDesiredTemp() - if math.Abs(ua.Desired_temp-new_temp) < 0.5 { - return - } - ua.Desired_temp = new_temp -} - -// 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() // either modifiy processFeedback loop or write a new one - case <-ctx.Done(): - return - } - } -} - -// - -func (ua *UnitAsset) processFeedbackLoop() { - // get the current temperature - /* - tf, err := usecases.GetState(ua.CervicesMap["setpoint"], ua.Owner) - if err != nil { - log.Printf("\n unable to obtain a setpoint 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 setpoint signal form") - return - } - */ - /* - miT := ua.getMin_temp().Value - maT := ua.getMax_temp().Value - miP := ua.getMin_price().Value - maP := ua.getMax_price().Value - */ - //ua.Desired_temp = ua.calculateDesiredTemp(miT, maT, miP, maP, ua.getSEK_price().Value) - // Only send temperature update when we have a new value. - if ua.Desired_temp == ua.old_desired_temp { - return - } - // Keep track of previous value - ua.old_desired_temp = ua.Desired_temp - - // perform the control algorithm - // ua.deviation = ua.Setpt - tup.Value - // output := ua.calculateOutput(ua.deviation) - - // prepare the form to send - var of forms.SignalA_v1a - of.NewForm() - of.Value = ua.Desired_temp - of.Unit = ua.CervicesMap["setpoint"].Details["Unit"][0] - of.Timestamp = time.Now() - - // pack the new valve state form - op, err := usecases.Pack(&of, "application/json") - if err != nil { - return - } - // send the new valve state request - err = usecases.SetState(ua.CervicesMap["setpoint"], ua.Owner, op) - if err != nil { - log.Printf("cannot update zigbee setpoint: %s\n", err) - return - } -} - -func (ua *UnitAsset) calculateDesiredTemp() float64 { - if ua.SEK_price <= ua.Min_price { - return ua.Max_temp - } - if ua.SEK_price >= ua.Max_price { - return ua.Min_temp - } - - k := -(ua.Max_temp - ua.Min_temp) / (ua.Max_price - ua.Min_price) - //m := max_temp - (k * min_price) - //m := max_temp - desired_temp := k*(ua.SEK_price-ua.Min_price) + ua.Min_temp // y - y_min = k*(x-x_min), solve for y ("desired temp") - return desired_temp -} diff --git a/ZigBeeValve/ZigBeeValve.go b/ZigBeeValve/ZigBeeValve.go index 12d44a5..91d0d5b 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,19 @@ 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 { + log.Println("Error sending setpoint:", err) + 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 82ef070..a6e1cec 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module github.com/lmas/d0020e_code go 1.23 require github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 + +require github.com/coder/websocket v1.8.12 // indirect diff --git a/go.sum b/go.sum index 0f4b1d6..668ac3a 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/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 h1:feRW3hSquROFeId8H0ZEUsH/kEzd4AAVxjsYkQd1cCs= github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4/go.mod h1:Bfx9Uj0uiTT7BCzzlImMiRd6vMoPQdsZIHGMQOVjx80=