diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1d0f615..4bc1691 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Tests and Linters +name: Linters, Spellcheck, and Tests on: push: @@ -7,7 +7,7 @@ on: jobs: Linters: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 2 steps: - uses: actions/checkout@v4 - name: Setup go @@ -19,15 +19,24 @@ jobs: - name: Run linters run: make lint + Spellcheck: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + - uses: crate-ci/typos@v1.29.7 + Tests: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 2 steps: - uses: actions/checkout@v4 - name: Setup go 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 index cd2480c..abb2bb8 100644 --- a/Comfortstat/Comfortstat.go +++ b/Comfortstat/Comfortstat.go @@ -20,7 +20,7 @@ func main() { // instantiate the System sys := components.NewSystem("Comfortstat", ctx) - // Instatiate the Capusle + // Instantiate the Capsule sys.Husk = &components.Husk{ Description: " is a controller for a consumed servo motor position based on a consumed temperature", Certificate: "ABCD", @@ -31,6 +31,9 @@ func main() { // instantiate a template unit asset assetTemplate := initTemplate() + // Calling initAPI() starts the pricefeedbackloop that fetches the current electricity price for the particular hour + initAPI() + time.Sleep(1 * time.Second) assetName := assetTemplate.GetName() sys.UAssets[assetName] = &assetTemplate @@ -45,8 +48,8 @@ func main() { if err := json.Unmarshal(raw, &uac); err != nil { log.Fatalf("Resource configuration error: %+v\n", err) } - ua, cleanup := newUnitAsset(uac, &sys, servsTemp) - defer cleanup() + ua, startup := newUnitAsset(uac, &sys, servsTemp) + startup() sys.UAssets[ua.GetName()] = &ua } @@ -66,83 +69,110 @@ func main() { 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 +// Serving handles the resources 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 "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) + case "MinTemperature": + t.httpSetMinTemp(w, r) + case "MaxTemperature": + t.httpSetMaxTemp(w, r) + case "MaxPrice": + t.httpSetMaxPrice(w, r) + case "MinPrice": + t.httpSetMinPrice(w, r) + case "SEKPrice": + t.httpSetSEKPrice(w, r) + case "DesiredTemp": + t.httpSetDesiredTemp(w, r) + case "userTemp": + t.httpSetUserTemp(w, r) + case "Region": + t.httpSetRegion(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) { +func (rsc *UnitAsset) httpSetSEKPrice(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": - signalErr := rsc.getSEK_price() + signalErr := rsc.getSEKPrice() 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) { +// All these functions below handles HTTP "PUT" or "GET" requests to modefy or retrieve the MAX/MIN temprature/price and desierd temperature +// 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 things.go with the value witch updates the value in the struct +func (rsc *UnitAsset) httpSetMinTemp(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) + //log.Println("Error with the setting request of the position ", err) + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } - rsc.setMin_temp(sig) + rsc.setMinTemp(sig) case "GET": - signalErr := rsc.getMin_temp() + signalErr := rsc.getMinTemp() 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) { +func (rsc *UnitAsset) httpSetMaxTemp(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) + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setMaxTemp(sig) + case "GET": + signalErr := rsc.getMaxTemp() + usecases.HTTPProcessGetRequest(w, r, &signalErr) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} + +func (rsc *UnitAsset) httpSetMinPrice(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) + //log.Println("Error with the setting request of the position ", err) + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return } - rsc.setMax_temp(sig) + rsc.setMinPrice(sig) case "GET": - signalErr := rsc.getMax_temp() + signalErr := rsc.getMinPrice() 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) { +func (rsc *UnitAsset) httpSetMaxPrice(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) + //log.Println("Error with the setting request of the position ", err) + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return } - rsc.setMin_price(sig) + rsc.setMaxPrice(sig) case "GET": - signalErr := rsc.getMin_price() + signalErr := rsc.getMaxPrice() usecases.HTTPProcessGetRequest(w, r, &signalErr) default: http.Error(w, "Method is not supported.", http.StatusNotFound) @@ -150,33 +180,53 @@ func (rsc *UnitAsset) set_minPrice(w http.ResponseWriter, r *http.Request) { } } -func (rsc *UnitAsset) set_maxPrice(w http.ResponseWriter, r *http.Request) { +func (rsc *UnitAsset) httpSetDesiredTemp(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) + //log.Println("Error with the setting request of the position ", err) + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return } - rsc.setMax_price(sig) + rsc.setDesiredTemp(sig) case "GET": - signalErr := rsc.getMax_price() + signalErr := rsc.getDesiredTemp() usecases.HTTPProcessGetRequest(w, r, &signalErr) default: http.Error(w, "Method is not supported.", http.StatusNotFound) + } + +} +func (rsc *UnitAsset) httpSetUserTemp(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.setUserTemp(sig) + case "GET": + signalErr := rsc.getUserTemp() + 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) { +func (rsc *UnitAsset) httpSetRegion(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) + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return } - rsc.setDesired_temp(sig) + rsc.setRegion(sig) case "GET": - signalErr := rsc.getDesired_temp() + signalErr := rsc.getRegion() usecases.HTTPProcessGetRequest(w, r, &signalErr) default: http.Error(w, "Method is not supported.", http.StatusNotFound) diff --git a/Comfortstat/Comfortstat_test.go b/Comfortstat/Comfortstat_test.go new file mode 100644 index 0000000..a6ec374 --- /dev/null +++ b/Comfortstat/Comfortstat_test.go @@ -0,0 +1,541 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHttpSetSEKPrice(t *testing.T) { + ua := initTemplate().(*UnitAsset) + + //Good case test: GET + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8670/Comfortstat/Set%20Values/SEKPrice", nil) + goodCode := 200 + ua.httpSetSEKPrice(w, r) + // calls the method and extracts the response and save is in resp for the upcoming tests + resp := w.Result() + if resp.StatusCode != goodCode { + t.Errorf("expected good status code: %v, got %v", goodCode, 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": 1.5`) + unit := strings.Contains(string(body), `"unit": "SEK"`) + 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!") + } + // Bad test case: default part of code + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8670/Comfortstat/Set%20Values/SEKPrice", nil) + // calls the method and extracts the response and save is in resp for the upcoming tests + ua.httpSetSEKPrice(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetMinTemp(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": 20, "unit": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/MinTemperature", 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. + goodStatusCode := 200 + ua.httpSetMinTemp(w, r) + + // save the rsponse 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": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/MinTemperature", 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.httpSetMinTemp(w, r) + // save the rsponse 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://localhost:8670/Comfortstat/Set%20Values/MinTemperature", nil) + goodStatusCode = 200 + ua.httpSetMinTemp(w, r) + + // save the rsponse 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": 20`) + unit := strings.Contains(string(body), `"unit": "Celsius"`) + 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://localhost:8670/Comfortstat/Set%20Values/MinTemperature", nil) + ua.httpSetMinTemp(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetMaxTemp(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": 25, "unit": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/MaxTemperature", 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. + goodStatusCode := 200 + ua.httpSetMaxTemp(w, r) + + // save the rsponse 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": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/MaxTemperature", 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.httpSetMaxTemp(w, r) + + // save the rsponse 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://localhost:8670/Comfortstat/Set%20Values/MaxTemperature", nil) + goodStatusCode = 200 + ua.httpSetMaxTemp(w, r) + + // save the rsponse 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": 25`) + unit := strings.Contains(string(body), `"unit": "Celsius"`) + 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", "localhost:8670/Comfortstat/Set%20Values/MaxTemperature", nil) + + ua.httpSetMaxTemp(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetMinPrice(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": 1, "unit": "SEK", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "localhost:8670/Comfortstat/Set%20Values/MinPrice", 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. + goodStatusCode := 200 + ua.httpSetMinPrice(w, r) + + // save the rsponse 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": "SEK", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/MinPrice", 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.httpSetMinPrice(w, r) + // save the rsponse + 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://localhost:8670/Comfortstat/Set%20Values/MinPrice", nil) + goodStatusCode = 200 + ua.httpSetMinPrice(w, r) + + // save the rsponse 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": 1`) + unit := strings.Contains(string(body), `"unit": "SEK"`) + 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://localhost:8670/Comfortstat/Set%20Values/MinPrice", nil) + ua.httpSetMinPrice(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) + } +} + +func TestHttpSetMaxPrice(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": 2, "unit": "SEK", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/MaxPrice", 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. + goodStatusCode := 200 + ua.httpSetMaxPrice(w, r) + + // save the rsponse 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": "SEK", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/MaxPrice", 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.httpSetMaxPrice(w, r) + + // save the rsponse 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://localhost:8670/Comfortstat/Set%20Values/MaxPrice", nil) + goodStatusCode = 200 + ua.httpSetMaxPrice(w, r) + + // save the rsponse 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": 2`) + unit := strings.Contains(string(body), `"unit": "SEK"`) + 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://localhost:8670/Comfortstat/Set%20Values/MaxPrice", nil) + + ua.httpSetMaxPrice(w, r) + resp = w.Result() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetDesiredTemp(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": 0, "unit": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/DesiredTemp", 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. + goodStatusCode := 200 + + ua.httpSetDesiredTemp(w, r) + + // save the rsponse 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": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/DesiredTemp", 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.httpSetDesiredTemp(w, r) + // save the rsponse 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://localhost:8670/Comfortstat/Set%20Values/DesiredTemp", nil) + goodStatusCode = 200 + ua.httpSetDesiredTemp(w, r) + + // save the rsponse 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": 0`) + unit := strings.Contains(string(body), `"unit": "Celsius"`) + 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://localhost:8670/Comfortstat/Set%20Values/DesiredTemp", nil) + // calls the method and extracts the response and save is in resp for the upcoming tests + ua.httpSetDesiredTemp(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetUserTemp(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": 0, "unit": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/userTemp", 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. + goodStatusCode := 200 + + ua.httpSetUserTemp(w, r) + + // save the rsponse 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": "Celsius", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/userTemp", 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.httpSetUserTemp(w, r) + // save the rsponse 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://localhost:8670/Comfortstat/Set%20Values/userTemp", nil) + goodStatusCode = 200 + ua.httpSetUserTemp(w, r) + + // save the rsponse 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": 0`) + unit := strings.Contains(string(body), `"unit": "Celsius"`) + 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://localhost:8670/Comfortstat/Set%20Values/userTemp", nil) + // calls the method and extracts the response and save is in resp for the upcoming tests + ua.httpSetUserTemp(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetRegion(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": 1, "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/Region", 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. + goodStatusCode := 200 + + ua.httpSetRegion(w, r) + + // save the rsponse 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, "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://localhost:8670/Comfortstat/Set%20Values/Region", 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.httpSetRegion(w, r) + // save the rsponse 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://localhost:8670/Comfortstat/Set%20Values/Region", nil) + goodStatusCode = 200 + ua.httpSetRegion(w, r) + + // save the rsponse 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": 1`) + version := strings.Contains(string(body), `"version": "SignalA_v1.0"`) + + if value != true { + t.Errorf("expected the 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://localhost:8670/Comfortstat/Set%20Values/Region", nil) + // calls the method and extracts the response and save is in resp for the upcoming tests + ua.httpSetRegion(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} 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 index 711e710..0f244cd 100644 --- a/Comfortstat/things.go +++ b/Comfortstat/things.go @@ -3,11 +3,12 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "io" "log" - "math" "net/http" + "net/url" "time" "github.com/sdoque/mbaigo/components" @@ -15,35 +16,148 @@ import ( "github.com/sdoque/mbaigo/usecases" ) +type GlobalPriceData struct { + SEKPrice float64 `json:"SEK_per_kWh"` + EURPrice float64 `json:"EUR_per_kWh"` + EXR float64 `json:"EXR"` + TimeStart string `json:"time_start"` + TimeEnd string `json:"time_end"` +} + +// initiate "globalPrice" with default values +var globalPrice = GlobalPriceData{ + SEKPrice: 0, + EURPrice: 0, + EXR: 0, + TimeStart: "0", + TimeEnd: "0", +} + // 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"` + 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"` + DesiredTemp float64 `json:"DesiredTemp"` + oldDesiredTemp float64 // keep this field private! + SEKPrice float64 `json:"SEK_per_kWh"` + MinPrice float64 `json:"MinPrice"` + MaxPrice float64 `json:"MaxPrice"` + MinTemp float64 `json:"MinTemp"` + MaxTemp float64 `json:"MaxTemp"` + UserTemp float64 `json:"UserTemp"` + Region float64 `json:"Region"` // the user can choose from what region the SEKPrice is taken from +} + +// SE1: Norra Sverige/Luleå (value = 1) +// SE2: Norra MellanSverige/Sundsvall (value = 2) +// SE3: Södra MellanSverige/Stockholm (value = 3) +// SE4: Södra Sverige/Kalmar (value = 4) + +func initAPI() { + go priceFeedbackLoop() +} + +const apiFetchPeriod int = 3600 + +var GlobalRegion float64 = 1 + +// defines the URL for the electricity price and starts the getAPIPriceData function once every hour +func priceFeedbackLoop() { + // Initialize a ticker for periodic execution + ticker := time.NewTicker(time.Duration(apiFetchPeriod) * time.Second) + defer ticker.Stop() + + url := fmt.Sprintf(`https://www.elprisetjustnu.se/api/v1/prices/%d/%02d-%02d_SE%d.json`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day(), int(GlobalRegion)) + // start the control loop + for { + err := getAPIPriceData(url) + if err != nil { + return + } + + select { + case <-ticker.C: + // blocks the execution until the ticker fires + } + } +} + +// This function checks if the user has changed price-region and then calls the getAPIPriceData function which gets the right pricedata +func switchRegion() { + urlSE1 := 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()) + urlSE2 := fmt.Sprintf(`https://www.elprisetjustnu.se/api/v1/prices/%d/%02d-%02d_SE2.json`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + urlSE3 := fmt.Sprintf(`https://www.elprisetjustnu.se/api/v1/prices/%d/%02d-%02d_SE3.json`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + urlSE4 := fmt.Sprintf(`https://www.elprisetjustnu.se/api/v1/prices/%d/%02d-%02d_SE4.json`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + + // SE1: Norra Sverige/Luleå (value = 1) + if GlobalRegion == 1 { + err := getAPIPriceData(urlSE1) + if err != nil { + return + } + } + // SE2: Norra MellanSverige/Sundsvall (value = 2) + if GlobalRegion == 2 { + err := getAPIPriceData(urlSE2) + if err != nil { + return + } + } + // SE3: Södra MellanSverige/Stockholm (value = 3) + if GlobalRegion == 3 { + err := getAPIPriceData(urlSE3) + if err != nil { + return + } + } + // SE4: Södra Sverige/Kalmar (value = 4) + if GlobalRegion == 4 { + err := getAPIPriceData(urlSE4) + if err != nil { + return + } + } } -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"` +var errStatuscode error = fmt.Errorf("bad status code") +var data []GlobalPriceData // Create a list to hold the data json + +// This function fetches the current electricity price from "https://www.elprisetjustnu.se/elpris-api", then process it and updates globalPrice +func getAPIPriceData(apiURL string) error { + //Validate the URL// + parsedURL, err := url.Parse(apiURL) // ensures the string is a valid URL, .schema and .Host checks prevent empty or altered URL + 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, &data) // "unpack" body from []byte to []GlobalPriceData, save errors + + defer res.Body.Close() + + if res.StatusCode > 299 { + return errStatuscode + } + if err != nil { + return err + } + return nil } // GetName returns the name of the Resource. @@ -73,67 +187,82 @@ 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 { - // 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", + + setSEKPrice := components.Service{ + Definition: "SEKPrice", + SubPath: "SEKPrice", 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?? + setMaxTemp := components.Service{ + Definition: "MaxTemperature", + SubPath: "MaxTemperature", + Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, Description: "provides the maximum temp the user wants (using a GET request)", } - setMin_temp := components.Service{ - Definition: "min_temperature", - SubPath: "min_temperature", + setMinTemp := components.Service{ + Definition: "MinTemperature", + SubPath: "MinTemperature", 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", + setMaxPrice := components.Service{ + Definition: "MaxPrice", + SubPath: "MaxPrice", 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", + setMinPrice := components.Service{ + Definition: "MinPrice", + SubPath: "MinPrice", 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", + setDesiredTemp := components.Service{ + Definition: "DesiredTemp", + SubPath: "DesiredTemp", 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)", } + setUserTemp := components.Service{ + Definition: "UserTemp", + SubPath: "UserTemp", + Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the temperature the user wants regardless of prices (using a GET request)", + } + setRegion := components.Service{ + Definition: "Region", + SubPath: "Region", + Details: map[string][]string{"Forms": {"SignalA_v1a"}}, + Description: "provides the temperature the user wants regardless of prices (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! + //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"}}, + SEKPrice: 1.5, // Example electricity price in SEK per kWh + MinPrice: 1.0, // Minimum price allowed + MaxPrice: 2.0, // Maximum price allowed + MinTemp: 20.0, // Minimum temperature + MaxTemp: 25.0, // Maximum temperature allowed + DesiredTemp: 0, // Desired temp calculated by system + Period: 15, + UserTemp: 0, + Region: 1, + + // maps 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, + setMaxTemp.SubPath: &setMaxTemp, + setMinTemp.SubPath: &setMinTemp, + setMaxPrice.SubPath: &setMaxPrice, + setMinPrice.SubPath: &setMinPrice, + setSEKPrice.SubPath: &setSEKPrice, + setDesiredTemp.SubPath: &setDesiredTemp, + setUserTemp.SubPath: &setUserTemp, + setRegion.SubPath: &setRegion, }, } } @@ -148,7 +277,7 @@ func newUnitAsset(uac UnitAsset, sys *components.System, servs []components.Serv sProtocol := components.SProtocols(sys.Husk.ProtoPort) - // the Cervice that is to be consumed by zigbee, there fore the name with the C + // the Cervice that is to be consumed by zigbee, therefore the name with the C t := &components.Cervice{ Name: "setpoint", @@ -158,17 +287,19 @@ func newUnitAsset(uac UnitAsset, sys *components.System, servs []components.Serv 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, + Name: uac.Name, + Owner: sys, + Details: uac.Details, + ServicesMap: components.CloneServices(servs), + SEKPrice: uac.SEKPrice, + MinPrice: uac.MinPrice, + MaxPrice: uac.MaxPrice, + MinTemp: uac.MinTemp, + MaxTemp: uac.MaxTemp, + DesiredTemp: uac.DesiredTemp, + Period: uac.Period, + UserTemp: uac.UserTemp, + Region: uac.Region, CervicesMap: components.Cervices{ t.Name: t, }, @@ -176,198 +307,124 @@ func newUnitAsset(uac UnitAsset, sys *components.System, servs []components.Serv var ref components.Service for _, s := range servs { - if s.Definition == "desired_temp" { + if s.Definition == "DesiredTemp" { 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 + // Returns the loaded unit asset and an function to handle return ua, func() { - log.Println("Cleaning up " + ua.Name) + // start the unit asset(s) + go ua.feedbackLoop(sys.Ctx) } } -// getSEK_price is used for reading the current hourly electric price -func (ua *UnitAsset) getSEK_price() (f forms.SignalA_v1a) { +// getSEKPrice is used for reading the current hourly electric price +func (ua *UnitAsset) getSEKPrice() (f forms.SignalA_v1a) { f.NewForm() - f.Value = ua.SEK_price + f.Value = ua.SEKPrice 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) -} +//Get and set- methods for MIN/MAX price/temp and desierdTemp -///////////////////////////////////////////////////////////////////////// -///////////////////////////////////////////////////////////////////////// - -// getMin_price is used for reading the current value of Min_price -func (ua *UnitAsset) getMin_price() (f forms.SignalA_v1a) { +// getMinPrice is used for reading the current value of MinPrice +func (ua *UnitAsset) getMinPrice() (f forms.SignalA_v1a) { f.NewForm() - f.Value = ua.Min_price + f.Value = ua.MinPrice 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() +// setMinPrice updates the current minimum price set by the user with a new value +func (ua *UnitAsset) setMinPrice(f forms.SignalA_v1a) { + ua.MinPrice = f.Value } -// getMax_price is used for reading the current value of Max_price -func (ua *UnitAsset) getMax_price() (f forms.SignalA_v1a) { +// getMaxPrice is used for reading the current value of MaxPrice +func (ua *UnitAsset) getMaxPrice() (f forms.SignalA_v1a) { f.NewForm() - f.Value = ua.Max_price + f.Value = ua.MaxPrice 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() +// setMaxPrice updates the current minimum price set by the user with a new value +func (ua *UnitAsset) setMaxPrice(f forms.SignalA_v1a) { + ua.MaxPrice = f.Value } -// getMin_temp is used for reading the current minimum temerature value -func (ua *UnitAsset) getMin_temp() (f forms.SignalA_v1a) { +// getMinTemp is used for reading the current minimum temperature value +func (ua *UnitAsset) getMinTemp() (f forms.SignalA_v1a) { f.NewForm() - f.Value = ua.Min_temp + f.Value = ua.MinTemp 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() +// setMinTemp updates the current minimum temperature set by the user with a new value +func (ua *UnitAsset) setMinTemp(f forms.SignalA_v1a) { + ua.MinTemp = f.Value } -// getMax_temp is used for reading the current value of Min_price -func (ua *UnitAsset) getMax_temp() (f forms.SignalA_v1a) { +// getMaxTemp is used for reading the current value of MinPrice +func (ua *UnitAsset) getMaxTemp() (f forms.SignalA_v1a) { f.NewForm() - f.Value = ua.Max_temp + f.Value = ua.MaxTemp 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() +// setMaxTemp updates the current minimum price set by the user with a new value +func (ua *UnitAsset) setMaxTemp(f forms.SignalA_v1a) { + ua.MaxTemp = f.Value } -func (ua *UnitAsset) getDesired_temp() (f forms.SignalA_v1a) { +func (ua *UnitAsset) getDesiredTemp() (f forms.SignalA_v1a) { f.NewForm() - f.Value = ua.Desired_temp + f.Value = ua.DesiredTemp 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) +func (ua *UnitAsset) setDesiredTemp(f forms.SignalA_v1a) { + ua.DesiredTemp = 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 (ua *UnitAsset) setUserTemp(f forms.SignalA_v1a) { + ua.UserTemp = f.Value + if ua.UserTemp != 0 { + ua.sendUserTemp() } } -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) +func (ua *UnitAsset) getUserTemp() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.UserTemp + f.Unit = "Celsius" + f.Timestamp = time.Now() + return f +} +func (ua *UnitAsset) setRegion(f forms.SignalA_v1a) { + ua.Region = f.Value + GlobalRegion = ua.Region + switchRegion() +} - // 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 +func (ua *UnitAsset) getRegion() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.Region + f.Timestamp = time.Now() + return f } // feedbackLoop is THE control loop (IPR of the system) @@ -380,56 +437,49 @@ func (ua *UnitAsset) feedbackLoop(ctx context.Context) { for { select { case <-ticker.C: - ua.processFeedbackLoop() // either modifiy processFeedback loop or write a new one + ua.processFeedbackLoop() // either modify processFeedback loop or write a new one case <-ctx.Done(): return } } } -// - +// this function adjust and sends a new desierd temperature to the zigbee system +// get the current best temperature 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 + ua.Region = GlobalRegion + // extracts the electricity price depending on the current time and updates globalPrice + 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.TimeStart == now { + globalPrice.SEKPrice = i.SEKPrice } - // 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") + } + + ua.SEKPrice = globalPrice.SEKPrice + + //ua.DesiredTemp = ua.calculateDesiredTemp(miT, maT, miP, maP, ua.getSEKPrice().Value) + ua.DesiredTemp = ua.calculateDesiredTemp() + // Only send temperature update when we have a new value. + if (ua.DesiredTemp == ua.oldDesiredTemp) || (ua.UserTemp != 0) { + if ua.UserTemp != 0 { + ua.oldDesiredTemp = ua.UserTemp 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) + ua.oldDesiredTemp = ua.DesiredTemp // prepare the form to send var of forms.SignalA_v1a of.NewForm() - of.Value = ua.Desired_temp + of.Value = ua.DesiredTemp of.Unit = ua.CervicesMap["setpoint"].Details["Unit"][0] of.Timestamp = time.Now() // pack the new valve state form + // Pack() converting the data in "of" into JSON format op, err := usecases.Pack(&of, "application/json") if err != nil { return @@ -442,17 +492,38 @@ func (ua *UnitAsset) processFeedbackLoop() { } } +// Calculates the new most optimal temperature (desierdTemp) based on the price/temprature intervals +// and the current electricity price func (ua *UnitAsset) calculateDesiredTemp() float64 { - if ua.SEK_price <= ua.Min_price { - return ua.Max_temp + + if ua.SEKPrice <= ua.MinPrice { + return ua.MaxTemp } - if ua.SEK_price >= ua.Max_price { - return ua.Min_temp + if ua.SEKPrice >= ua.MaxPrice { + return ua.MinTemp } - 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 + k := (ua.MinTemp - ua.MaxTemp) / (ua.MaxPrice - ua.MinPrice) + m := ua.MaxTemp - (k * ua.MinPrice) + DesiredTemp := k*(ua.SEKPrice) + m + + return DesiredTemp +} + +func (ua *UnitAsset) sendUserTemp() { + var of forms.SignalA_v1a + of.NewForm() + of.Value = ua.UserTemp + of.Unit = ua.CervicesMap["setpoint"].Details["Unit"][0] + of.Timestamp = time.Now() + + op, err := usecases.Pack(&of, "application/json") + if err != nil { + return + } + err = usecases.SetState(ua.CervicesMap["setpoint"], ua.Owner, op) + if err != nil { + log.Printf("cannot update zigbee setpoint: %s\n", err) + return + } } diff --git a/Comfortstat/things_test.go b/Comfortstat/things_test.go new file mode 100644 index 0000000..74eb019 --- /dev/null +++ b/Comfortstat/things_test.go @@ -0,0 +1,501 @@ +package main + +import ( + "context" + "fmt" + "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 +} + +// price example string in a JSON-like format +var priceExample string = fmt.Sprintf(`[{ + "SEK_per_kWh": 0.26673, + "EUR_per_kWh": 0.02328, + "EXR": 11.457574, + "time_start": "%d-%02d-%02dT%02d:00:00+01:00", + "time_end": "2025-01-06T04:00:00+01:00" + }]`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day(), time.Now().Local().Hour(), +) + +// 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 = "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) { + 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 + ua := initTemplate().(*UnitAsset) + //retrieveAPIPrice(ua) + ua.getSEKPrice() + + // 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 TestMultipleUnitAssetOneAPICall(t *testing.T) { + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + } + trans := newMockTransport(resp) + // Creates multiple UnitAssets and monitor their API requests + units := 10 + for i := 0; i < units; i++ { + ua := initTemplate().(*UnitAsset) + //retrieveAPIPrice(ua) + ua.getSEKPrice() + } + // 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) + } +} + +func TestSetmethods(t *testing.T) { + asset := initTemplate().(*UnitAsset) + + // Simulate the input signals + MinTempInputSignal := forms.SignalA_v1a{ + Value: 1.0, + } + //call and test MinTemp + asset.setMinTemp(MinTempInputSignal) + if asset.MinTemp != 1.0 { + t.Errorf("expected MinTemp to be 1.0, got %f", asset.MinTemp) + } + // Simulate the input signals + MaxTempInputSignal := forms.SignalA_v1a{ + Value: 29.0, + } + // call and test MaxTemp + asset.setMaxTemp(MaxTempInputSignal) + if asset.MaxTemp != 29.0 { + t.Errorf("expected MaxTemp to be 25.0, got %f", asset.MaxTemp) + } + // Simulate the input signals + MinPriceInputSignal := forms.SignalA_v1a{ + Value: 2.0, + } + //call and test MinPrice + asset.setMinPrice(MinPriceInputSignal) + if asset.MinPrice != 2.0 { + t.Errorf("expected MinPrice to be 2.0, got %f", asset.MinPrice) + } + // Simulate the input signals + MaxPriceInputSignal := forms.SignalA_v1a{ + Value: 12.0, + } + //call and test MaxPrice + asset.setMaxPrice(MaxPriceInputSignal) + if asset.MaxPrice != 12.0 { + t.Errorf("expected MaxPrice to be 12.0, got %f", asset.MaxPrice) + } + // Simulate the input signals + DesTempInputSignal := forms.SignalA_v1a{ + Value: 23.7, + } + // call and test DesiredTemp + asset.setDesiredTemp(DesTempInputSignal) + if asset.DesiredTemp != 23.7 { + t.Errorf("expected Desierd temperature is to be 23.7, got %f", asset.DesiredTemp) + } + // Simulate the input signal and call the set method for the check + RegionInputSignalSE2 := forms.SignalA_v1a{ + Value: 2, + } + asset.setRegion(RegionInputSignalSE2) + if asset.Region != 2.0 { + t.Errorf("expected Region to be SE2 (2), got %f", asset.Region) + } + // Simulate the input signal and call the set method for the check + RegionInputSignalSE3 := forms.SignalA_v1a{ + Value: 3, + } + asset.setRegion(RegionInputSignalSE3) + if asset.Region != 3.0 { + t.Errorf("expected Region to be SE3 (3), got %f", asset.Region) + } + // Simulate the input signal and call the set method for the check + RegionInputSignalSE1 := forms.SignalA_v1a{ + Value: 1, + } + asset.setRegion(RegionInputSignalSE1) + if asset.Region != 1.0 { + t.Errorf("expected Region to be SE1 (1), got %f", asset.Region) + } + // Simulate the input signal and call the set method for the check + RegionInputSignalSE4 := forms.SignalA_v1a{ + Value: 4, + } + asset.setRegion(RegionInputSignalSE4) + if asset.Region != 4.0 { + t.Errorf("expected Region to be SE4 (4), got %f", asset.Region) + } + +} + +func TestGetMethods(t *testing.T) { + uasset := initTemplate().(*UnitAsset) + + ////MinTemp//// + // check if the value from the struct is the actual value that the func is getting + result := uasset.getMinTemp() + if result.Value != uasset.MinTemp { + t.Errorf("expected Value of the MinTemp is to be %v, got %v", uasset.MinTemp, result.Value) + } + //check that the Unit is correct + if result.Unit != "Celsius" { + t.Errorf("expected Unit to be 'Celsius', got %v", result.Unit) + } + ////MaxTemp//// + result2 := uasset.getMaxTemp() + if result2.Value != uasset.MaxTemp { + t.Errorf("expected Value of the MaxTemp is to be %v, got %v", uasset.MaxTemp, result2.Value) + } + //check that the Unit is correct + if result2.Unit != "Celsius" { + t.Errorf("expected Unit of the MaxTemp is to be 'Celsius', got %v", result2.Unit) + } + ////MinPrice//// + // check if the value from the struct is the actual value that the func is getting + result3 := uasset.getMinPrice() + if result3.Value != uasset.MinPrice { + t.Errorf("expected Value of the minPrice is to be %v, got %v", uasset.MinPrice, result3.Value) + } + //check that the Unit is correct + if result3.Unit != "SEK" { + t.Errorf("expected Unit to be 'SEK', got %v", result3.Unit) + } + ////MaxPrice//// + // check if the value from the struct is the actual value that the func is getting + result4 := uasset.getMaxPrice() + if result4.Value != uasset.MaxPrice { + t.Errorf("expected Value of the maxPrice is to be %v, got %v", uasset.MaxPrice, result4.Value) + } + //check that the Unit is correct + if result4.Unit != "SEK" { + t.Errorf("expected Unit to be 'SEK', got %v", result4.Unit) + } + ////DesierdTemp//// + // check if the value from the struct is the actual value that the func is getting + result5 := uasset.getDesiredTemp() + if result5.Value != uasset.DesiredTemp { + t.Errorf("expected desired temperature is to be %v, got %v", uasset.DesiredTemp, result5.Value) + } + //check that the Unit is correct + if result5.Unit != "Celsius" { + t.Errorf("expected Unit to be 'Celsius', got %v", result5.Unit) + } + ////SEKPrice//// + result6 := uasset.getSEKPrice() + if result6.Value != uasset.SEKPrice { + t.Errorf("expected electric price is to be %v, got %v", uasset.SEKPrice, result6.Value) + } + ////USertemp//// + result7 := uasset.getUserTemp() + if result7.Value != uasset.UserTemp { + t.Errorf("expected the Usertemp to be %v, got %v", uasset.UserTemp, result7.Value) + } + ////Region//// + result8 := uasset.getRegion() + if result8.Value != uasset.Region { + t.Errorf("expected the Region to be %v, got %v", uasset.Region, result8.Value) + } +} + +func TestInitTemplate(t *testing.T) { + uasset := initTemplate().(*UnitAsset) + + //// unnecessary test, but good for practicing + name := uasset.GetName() + expected := "Set_Values" + if name != expected { + t.Errorf("expected name of the resource to be %v, got %v", expected, name) + } + Services := uasset.GetServices() + if Services == nil { + t.Fatalf("If Services is nil, not worth to continue testing") + } + //Services// + if Services["SEKPrice"].Definition != "SEKPrice" { + t.Errorf("expected service definition to be SEKprice") + } + if Services["MaxTemperature"].Definition != "MaxTemperature" { + t.Errorf("expected service definition to be MaxTemperature") + } + if Services["MinTemperature"].Definition != "MinTemperature" { + t.Errorf("expected service definition to be MinTemperature") + } + if Services["MaxPrice"].Definition != "MaxPrice" { + t.Errorf("expected service definition to be MaxPrice") + } + if Services["MinPrice"].Definition != "MinPrice" { + t.Errorf("expected service definition to be MinPrice") + } + if Services["DesiredTemp"].Definition != "DesiredTemp" { + t.Errorf("expected service definition to be DesiredTemp") + } + //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("Comfortstat", ctx) + + // Instantiate the Capsule + 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", + } + setSEKPrice := components.Service{ + Definition: "SEKPrice", + SubPath: "SEKPrice", + Details: map[string][]string{"Unit": {"SEK"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current electric hourly price (using a GET request)", + } + setMaxTemp := components.Service{ + Definition: "MaxTemperature", + SubPath: "MaxTemperature", + Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the maximum temp the user wants (using a GET request)", + } + setMinTemp := components.Service{ + Definition: "MinTemperature", + SubPath: "MinTemperature", + Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the minimum temp the user could tolerate (using a GET request)", + } + setMaxPrice := components.Service{ + Definition: "MaxPrice", + SubPath: "MaxPrice", + Details: map[string][]string{"Unit": {"SEK"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the maximum price the user wants to pay (using a GET request)", + } + setMinPrice := components.Service{ + Definition: "MinPrice", + SubPath: "MinPrice", + Details: map[string][]string{"Unit": {"SEK"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the minimum price the user wants to pay (using a GET request)", + } + setDesiredTemp := components.Service{ + Definition: "DesiredTemp", + SubPath: "DesiredTemp", + 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)", + } + // new Unitasset struct init. + uac := UnitAsset{ + //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"}}, + SEKPrice: 1.5, // Example electricity price in SEK per kWh + MinPrice: 1.0, // Minimum price allowed + MaxPrice: 2.0, // Maximum price allowed + MinTemp: 20.0, // Minimum temperature + MaxTemp: 25.0, // Maximum temperature allowed + DesiredTemp: 0, // Desired temp calculated by system + Period: 15, + + // maps the provided services from above + ServicesMap: components.Services{ + setMaxTemp.SubPath: &setMaxTemp, + setMinTemp.SubPath: &setMinTemp, + setMaxPrice.SubPath: &setMaxPrice, + setMinPrice.SubPath: &setMinPrice, + setSEKPrice.SubPath: &setSEKPrice, + setDesiredTemp.SubPath: &setDesiredTemp, + }, + } + + ua, _ := newUnitAsset(uac, &sys, nil) + // Calls the method that gets the name of the new unitasset. + name := ua.GetName() + if name != "Set Values" { + t.Errorf("expected name to be Set values, but got: %v", name) + } +} + +// Test if the method calculateDesierdTemp() calculates a correct value +func TestCalculateDesiredTemp(t *testing.T) { + var True_result float64 = 22.5 + asset := initTemplate().(*UnitAsset) + // calls and saves the value + result := asset.calculateDesiredTemp() + // checks if actual calculated value matches the expected value + if result != True_result { + t.Errorf("Expected calculated temp is %v, got %v", True_result, result) + } +} + +// This test catches the special cases, when the temperature is to be set to the minimum temperature right away +func TestSpecialCalculate(t *testing.T) { + asset := UnitAsset{ + SEKPrice: 3.0, + MaxPrice: 2.0, + MinTemp: 17.0, + } + //call the method and save the result in a variable for testing + result := asset.calculateDesiredTemp() + //check the result from the call above + if result != asset.MinTemp { + t.Errorf("Expected temperature to be %v, got %v", asset.MinTemp, result) + } +} + +// 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 TestGetAPIPriceData(t *testing.T) { + // creating a price example, nessasry fore the test + priceExample = fmt.Sprintf(`[{ + "SEK_per_kWh": 0.26673, + "EUR_per_kWh": 0.02328, + "EXR": 11.457574, + "time_start": "%d-%02d-%02dT%02d:00:00+01:00", + "time_end": "2025-01-06T04:00:00+01:00" + }]`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day(), time.Now().Local().Hour(), + ) + // creates a fake response + fakeBody := fmt.Sprintf(priceExample) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + // Testing good cases + // Test case: goal is no errors + 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(), + ) + // creates a mock HTTP transport to simulate api response for the test + newMockTransport(resp) + err := getAPIPriceData(url) + if err != nil { + t.Errorf("expected no errors but got %s :", err) + } + // Check if the correct price is stored + // expectedPrice := 0.26673 + // if globalPrice.SEKPrice != expectedPrice { + // t.Errorf("Expected SEKPrice %f, but got %f", expectedPrice, globalPrice.SEKPrice) + // } + // Testing bad cases + // Test case: using wrong url leads to an error + newMockTransport(resp) + // Call the function (which now hits the mock server) + err = getAPIPriceData(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 = getAPIPriceData(url) + 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 = getAPIPriceData(url) + // 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) + } + // test case: if unmarshal a bad body creates a error + resp.StatusCode = 200 + resp.Body = io.NopCloser(strings.NewReader(fakeBody + "123")) + newMockTransport(resp) + err = getAPIPriceData(url) + // make the check if the unmarshal creates a error + if err == nil { + t.Errorf("expected an error, got %v :", err) + } +} diff --git a/Makefile b/Makefile index 6b6d159..8597a08 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,14 @@ lint: test -z $$(gofmt -l .) || (echo "Code isn't gofmt'ed!" && exit 1) go vet $$(go list ./... | grep -v /tmp) gosec -quiet -fmt=golint -exclude-dir="tmp" ./... + pointerinterface ./... + +# Runs spellchecker on the code and comments +# This requires this tool to be installed from https://github.com/crate-ci/typos?tab=readme-ov-file +# Example installation: +# cargo install typos-cli +spellcheck: + typos . # Generate pretty coverage report analyse: @@ -19,13 +27,14 @@ analyse: @echo -e "\nCOVERAGE\n====================" go tool cover -func=.cover.out @echo -e "\nCYCLOMATIC COMPLEXITY\n====================" - gocyclo -avg -top 10 . + gocyclo -avg -top 10 -ignore test.go . # Updates 3rd party packages and tools deps: go mod download go install github.com/securego/gosec/v2/cmd/gosec@latest go install github.com/fzipp/gocyclo/cmd/gocyclo@latest + go install code.larus.se/lmas/pointerinterface@latest # Show documentation of public parts of package, in the current dir docs: @@ -41,4 +50,3 @@ build: clean: go clean rm .cover.out cover.html - # TODO: add raspberrypi bins diff --git a/README.md b/README.md index d91e7cf..bc1a30a 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,14 @@ "Demand Response with ZigBee and mbaigo." -The public code repo. +**The public code repo.** + +## Description + +This project aims to reduce society's energy consumption by controlling smart power +devices, making adjustments according to the daily power market and the local weather. +Powered by a Internet-of-Things cloud that you can run locally and privately, +using an alternative [Arrowhead] implementation in [Go]. + +[Arrowhead]: https://arrowhead.eu/eclipse-arrowhead-2/ +[Go]: https://github.com/sdoque/mbaigo diff --git a/ZigBeeValve/ZigBeeValve.go b/ZigBeeValve/ZigBeeValve.go index 12d44a5..d0cd301 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 + // Instantiate the Capsule 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 } @@ -69,13 +75,13 @@ func main() { time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } -// Serving handles the resources services. NOTE: it exepcts those names from the request URL path +// Serving handles the resources 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 "setpoint": t.setpt(w, r) default: - http.Error(w, "Invalid service request [Do not modify the services subpath in the configurration file]", http.StatusBadRequest) + http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) } } @@ -87,16 +93,18 @@ func (rsc *UnitAsset) setpt(w http.ResponseWriter, r *http.Request) { case "PUT": sig, err := usecases.HTTPProcessSetRequest(w, r) if err != nil { - log.Println("Error with the setting desired temp ", err) + http.Error(w, "Request incorrectly formatted", http.StatusBadRequest) + return } - log.Println("sig:", sig) - log.Println("URL:", r.URL) - log.Println("Model:", rsc.Model) + rsc.setSetPoint(sig) - if rsc.Model == "SmartThermostat" { - rsc.sendSetPoint() + if rsc.Model == "ZHAThermostat" { + err = rsc.sendSetPoint() + if err != nil { + http.Error(w, "Couldn't send setpoint.", http.StatusInternalServerError) + return + } } - default: http.Error(w, "Method is not supported.", http.StatusNotFound) } diff --git a/ZigBeeValve/thing.go b/ZigBeeValve/thing.go index d62ef63..8ab8268 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. @@ -69,7 +71,7 @@ func (ua *UnitAsset) GetDetails() map[string][]string { // ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation) var _ components.UnitAsset = (*UnitAsset)(nil) -//-------------------------------------Instatiate a unit asset template +//-------------------------------------Instantiate a unit asset template // initTemplate initializes a UnitAsset with default values. func initTemplate() components.UnitAsset { @@ -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, }, @@ -96,11 +106,13 @@ func initTemplate() components.UnitAsset { return uat } -//-------------------------------------Instatiate the unit assets based on configuration +//-------------------------------------Instantiate 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 + // determine the protocols that the system supports sProtocols := components.SProtocols(sys.Husk.ProtoPort) // instantiate the consumed services @@ -109,43 +121,59 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi Protos: sProtocols, Url: make([]string, 0), } - - // intantiate the unit asset + // instantiate the unit asset ua := &UnitAsset{ Name: uac.Name, Owner: sys, 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 occurred during startup, while calling getConnectedUnits:", err) + } + */ + err := ua.sendSetPoint() + if err != nil { + log.Println("Error occurred during startup, while calling sendSetPoint():", err) + // TODO: Turn off system if this startup() fails? + } + } else if ua.Model == "Smart plug" { + /* + // Get correct index in list returned by api/lights to make sure we always change correct device + err := ua.getConnectedUnits("lights") + if err != nil { + log.Println("Error occurred during startup, while calling getConnectedUnits:", err) + } + */ + // Not all smart plugs should be handled by the feedbackloop, some should be handled by a switch + if ua.Period != 0 { + // start the unit assets feedbackloop, this fetches the temperature from ds18b20 and and toggles + // between on/off depending on temperature in the room and a set temperature in the unitasset + go ua.feedbackLoop(ua.Owner.Ctx) + } + } } } @@ -153,7 +181,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 +199,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 occurred while toggling state to true: ", err) + } } else { - ua.toggleState(false) + err = ua.toggleState(false) + if err != nil { + log.Println("Error occurred 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 +265,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 +273,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.Uniqueid + "/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.Uniqueid + "/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 unmarshalling 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..ee31ba7 --- /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, + } + // Hijack the default http client so no actual 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 expected 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 occurred:", 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 occurred, 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 occurred:", err) + } + + // Break defaultClient.Do() + // --- Error performing request --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + s = fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload + req, _ = 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 occurred:") + } + + // 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..c9e4f72 --- /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 statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + + // --- 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/_typos.toml b/_typos.toml new file mode 100644 index 0000000..8ff0764 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,4 @@ +[default.extend-words] +# This spelling error is caused in the mbaigo systems +Celcius = "Celcius" + diff --git a/collector/collect_test.go b/collector/collect_test.go new file mode 100644 index 0000000..2cb08f5 --- /dev/null +++ b/collector/collect_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/forms" + "github.com/sdoque/mbaigo/usecases" +) + +type mockTransport struct { + respCode int + respBody io.ReadCloser + + // hits map[string]int + // returnError bool + // resp *http.Response + // err error +} + +func newMockTransport() mockTransport { + t := mockTransport{ + respCode: 200, + respBody: io.NopCloser(strings.NewReader("")), + + // hits: make(map[string]int), + // err: err, + // returnError: retErr, + // resp: resp, + } + // Hijack the default http client so no actual http requests are sent over the network + http.DefaultClient.Transport = t + return t +} + +func (t mockTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + // log.Println("HIJACK:", req.URL.String()) + // 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 + + // b, err := io.ReadAll(req.Body) + // if err != nil { + // return + // } + // fmt.Println(string(b)) + + return &http.Response{ + Request: req, + StatusCode: t.respCode, + Body: t.respBody, + }, nil +} + +const mockBodyType string = "application/json" + +var mockStates = map[string]string{ + "temperature": `{ "value": 0, "unit": "Celcius", "timestamp": "%s", "version": "SignalA_v1.0" }`, + "SEKPrice": `{ "value": 0.10403, "unit": "SEK", "timestamp": "%s", "version": "SignalA_v1.0" }`, + "DesiredTemp": `{ "value": 25, "unit": "Celsius", "timestamp": "%s", "version": "SignalA_v1.0" }`, + "setpoint": `{ "value": 20, "unit": "Celsius", "timestamp": "%s", "version": "SignalA_v1.0" }`, +} + +func mockGetState(c *components.Cervice, s *components.System) (f forms.Form, err error) { + if c == nil { + err = fmt.Errorf("got empty *Cervice instance") + return + } + b := mockStates[c.Name] + if len(b) < 1 { + err = fmt.Errorf("found no mock body for service: %s", c.Name) + return + } + body := fmt.Sprintf(b, time.Now().Format(time.RFC3339)) + f, err = usecases.Unpack([]byte(body), mockBodyType) + if err != nil { + err = fmt.Errorf("failed to unpack mock body: %s", err) + } + return +} + +func TestCollectService(t *testing.T) { + newMockTransport() + ua := newUnitAsset(*initTemplate(), newSystem(), nil) + ua.apiGetState = mockGetState + + // for _, service := range consumeServices { + // err := ua.collectService(service) + // if err != nil { + // t.Fatalf("Expected nil error while pulling %s, got: %s", service, err) + // } + // } + err := ua.collectAllServices() + if err != nil { + t.Fatalf("Expected nil error, got: %s", err) + } +} diff --git a/collector/system.go b/collector/system.go new file mode 100644 index 0000000..5cc605f --- /dev/null +++ b/collector/system.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "sync" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +func main() { + sys := newSystem() + sys.loadConfiguration() + + // Generate PKI keys and CSR to obtain a authentication certificate from the CA + usecases.RequestCertificate(&sys.System) + + // Register the system and its services + // WARN: this func runs a goroutine of it's own, which makes it hard to count + // using the waitgroup (and I can't be arsed to do it properly...) + usecases.RegisterServices(&sys.System) + + // Run forever + sys.listenAndServe() +} + +//////////////////////////////////////////////////////////////////////////////// + +// There's no interface to use, so have to encapsulate the base struct instead +type system struct { + components.System + + cancel func() + startups []func() error +} + +func newSystem() (sys *system) { + // Handle graceful shutdowns using this context. It should always be canceled, + // no matter the final execution path so all computer resources are freed up. + ctx, cancel := context.WithCancel(context.Background()) + + // Create a new Eclipse Arrowhead application system and then wrap it with a + // "husk" (aka a wrapper or shell), which then sets up various properties and + // operations that's required of an Arrowhead system. + // var sys system + sys = &system{ + System: components.NewSystem("Collector", ctx), + cancel: cancel, + } + sys.Husk = &components.Husk{ + Description: "pulls data from other Arrorhead systems and sends it to a InfluxDB server.", + Details: map[string][]string{"Developer": {"Alex"}}, + ProtoPort: map[string]int{"https": 6666, "http": 6666, "coap": 0}, + InfoLink: "https://github.com/lmas/d0020e_code/tree/master/collector", + } + return +} + +func (sys *system) loadConfiguration() { + // Try loading the config file (in JSON format) for this deployment, + // by using a unit asset with default values. + uat := components.UnitAsset(initTemplate()) + sys.UAssets[uat.GetName()] = &uat + rawUAs, servsTemp, err := usecases.Configure(&sys.System) + // If the file is missing, a new config will be created and an error is returned here. + if err != nil { + // TODO: it would had been nice to catch the exact error for "created config.." + // and not display it as an actual error, per se. + log.Fatalf("Error while reading configuration: %v\n", err) + } + + // Load the proper unit asset(s) using the user-defined settings from the config file. + clear(sys.UAssets) + for _, raw := range rawUAs { + var uac unitAsset + if err := json.Unmarshal(raw, &uac); err != nil { + log.Fatalf("Error while unmarshalling configuration: %+v\n", err) + } + // ua, startup := newUnitAsset(uac, &sys.System, servsTemp) + // ua := newUnitAsset(uac, &sys.System, servsTemp) + ua := newUnitAsset(uac, sys, servsTemp) + sys.startups = append(sys.startups, ua.startup) + intf := components.UnitAsset(ua) + sys.UAssets[ua.GetName()] = &intf + } +} + +func (sys *system) listenAndServe() { + var wg sync.WaitGroup // Used for counting all started goroutines + + // start a web server that serves basic documentation of the system + wg.Add(1) + go func() { + if err := usecases.SetoutServers(&sys.System); err != nil { + log.Println("Error while running web server:", err) + sys.cancel() + } + wg.Done() + }() + + // Run all the startups in separate goroutines and keep track of them + for _, f := range sys.startups { + wg.Add(1) + go func(start func() error) { + if err := start(); err != nil { + log.Printf("Error while running collector: %s\n", err) + sys.cancel() + } + wg.Done() + }(f) + } + + // Block and wait for either a... + select { + case <-sys.Sigs: // user initiated shutdown signal (ctrl+c) or a... + case <-sys.Ctx.Done(): // shutdown request from a worker + } + + // Gracefully terminate any leftover goroutines and wait for them to shutdown properly + log.Println("Initiated shutdown, waiting for workers to terminate") + sys.cancel() + wg.Wait() +} diff --git a/collector/unitasset.go b/collector/unitasset.go new file mode 100644 index 0000000..34f68cf --- /dev/null +++ b/collector/unitasset.go @@ -0,0 +1,229 @@ +package main + +// This file was originally copied from: +// https://github.com/sdoque/systems/blob/main/ds18b20/thing.go + +import ( + "fmt" + "log" + "net/http" + "sync" + "time" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/influxdb-client-go/v2/api" + "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" +// +// This unit asset represents a room's statistics cache, where all data from the +// other system are gathered, before being sent off to the InfluxDB service. +type unitAsset struct { + // These fields are required by the interface (below) and must also be public + // for being included in the systemconfig JSON file. + 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:"-"` // Services provided to consumers + CervicesMap components.Cervices `json:"-"` // Services being consumed + + InfluxDBHost string `json:"influxdb_host"` // IP:port addr to the influxdb server + InfluxDBToken string `json:"influxdb_token"` // Auth token + InfluxDBOrganisation string `json:"influxdb_organisation"` + InfluxDBBucket string `json:"influxdb_bucket"` // Data bucket + CollectionPeriod int `json:"collection_period"` // Period (in seconds) between each data collection + + // Mockable function for getting states from the consumed services. + apiGetState func(*components.Cervice, *components.System) (forms.Form, error) + + // + influx influxdb2.Client + influxWriter api.WriteAPI +} + +// Following methods are required by the interface components.UnitAsset. +// Enforce a compile-time check that the interface is implemented correctly. +var _ components.UnitAsset = (*unitAsset)(nil) + +func (ua *unitAsset) GetName() string { + return ua.Name +} + +func (ua *unitAsset) GetDetails() map[string][]string { + return ua.Details +} + +func (ua *unitAsset) GetServices() components.Services { + return ua.ServicesMap +} + +func (ua *unitAsset) GetCervices() components.Cervices { + return ua.CervicesMap +} + +func (ua *unitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) { + // We don't provide any services! + http.Error(w, "No services available", http.StatusNotImplemented) +} + +//////////////////////////////////////////////////////////////////////////////// + +const uaName string = "Cache" + +// 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 { +func initTemplate() *unitAsset { + return &unitAsset{ + Name: uaName, + Details: map[string][]string{"Location": {"Kitchen"}}, + + InfluxDBHost: "http://localhost:8086", + InfluxDBToken: "insert secret token here", + InfluxDBOrganisation: "organisation", + InfluxDBBucket: "arrowhead", + CollectionPeriod: 30, + } +} + +var consumeServices []string = []string{ + "temperature", + "SEKPrice", + "DesiredTemp", + "setpoint", +} + +// 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 ... +// TODO: complete doc and remove servs here and in the system file +// func newUnitAsset(uac unitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func() error) { +// func newUnitAsset(uac unitAsset, sys *components.System, servs []components.Service) *unitAsset { +func newUnitAsset(uac unitAsset, sys *system, servs []components.Service) *unitAsset { + client := influxdb2.NewClientWithOptions( + uac.InfluxDBHost, uac.InfluxDBToken, + influxdb2.DefaultOptions().SetHTTPClient(http.DefaultClient), + ) + + ua := &unitAsset{ + Name: uac.Name, + Owner: &sys.System, + Details: uac.Details, + // ServicesMap: components.CloneServices(servs), // TODO: not required? + CervicesMap: components.Cervices{}, + + InfluxDBHost: uac.InfluxDBHost, + InfluxDBToken: uac.InfluxDBToken, + InfluxDBOrganisation: uac.InfluxDBOrganisation, + InfluxDBBucket: uac.InfluxDBBucket, + CollectionPeriod: uac.CollectionPeriod, + + apiGetState: usecases.GetState, + influx: client, + influxWriter: client.WriteAPI(uac.InfluxDBOrganisation, uac.InfluxDBBucket), + } + + // TODO: handle influx write errors or don't care? + + // Prep all the consumed services + protos := components.SProtocols(sys.Husk.ProtoPort) + for _, service := range consumeServices { + ua.CervicesMap[service] = &components.Cervice{ + Name: service, + Protos: protos, + Url: make([]string, 0), + } + } + + // TODO: required for matching values with locations? + // ua.CervicesMap["temperature"].Details = components.MergeDetails(ua.Details, nil) + // for _, cs := range ua.CervicesMap { + // TODO: or merge it with an empty map if this doesn't work... + // cs.Details = ua.Details + // } + + // Returns the loaded unit asset and an function to handle optional cleanup at shutdown + // return ua, ua.startup + return ua +} + +//////////////////////////////////////////////////////////////////////////////// + +var errTooShortPeriod error = fmt.Errorf("collection period less than 1 second") + +func (ua *unitAsset) startup() (err error) { + if ua.CollectionPeriod < 1 { + return errTooShortPeriod + } + + // TODO: try connecting to influx, check if need to call Health()/Ping()/Ready()/Setup()? + + for { + select { + // Wait for a shutdown signal + case <-ua.Owner.Ctx.Done(): + ua.cleanup() + return + + // Wait until it's time to collect new data + case <-time.Tick(time.Duration(ua.CollectionPeriod) * time.Second): + if err = ua.collectAllServices(); err != nil { + return + } + } + } +} + +func (ua *unitAsset) cleanup() { + ua.influx.Close() +} + +func (ua *unitAsset) collectAllServices() (err error) { + // log.Println("tick") // TODO + var wg sync.WaitGroup + + for _, service := range consumeServices { + wg.Add(1) + go func(s string) { + if err := ua.collectService(s); err != nil { + log.Printf("Error collecting data from %s: %s", s, err) + } + wg.Done() + }(service) + } + + wg.Wait() + ua.influxWriter.Flush() + return nil +} + +func (ua *unitAsset) collectService(service string) (err error) { + f, err := ua.apiGetState(ua.CervicesMap[service], ua.Owner) + if err != nil { + return // TODO: use a better error? + } + // fmt.Println(f) + s, ok := f.(*forms.SignalA_v1a) + if !ok { + err = fmt.Errorf("bad form version: %s", f.FormVersion()) + return + } + // fmt.Println(s) // TODO + + p := influxdb2.NewPoint( + service, + map[string]string{"unit": s.Unit}, + map[string]interface{}{"value": s.Value}, + s.Timestamp.UTC(), + ) + // fmt.Println(p) + + ua.influxWriter.WritePoint(p) + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml index 19d0e8c..e385843 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ services: network_mode: "host" volumes: - ./data/registrar:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro orchestrator: image: orchestrator:0.1.0 @@ -37,6 +39,8 @@ services: network_mode: "host" volumes: - ./data/orchestrator:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro # Build and run business systems @@ -53,6 +57,8 @@ services: network_mode: "host" volumes: - ./data/ds18b20:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro comfortstat: image: comfortstat:0.2.0 @@ -67,6 +73,8 @@ services: network_mode: "host" volumes: - ./data/comfortstat:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro zigbee: image: zigbee:0.2.0 @@ -82,24 +90,41 @@ services: network_mode: "host" volumes: - ./data/zigbee:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro - # influxdb: - # image: influxdb:x.x.x-alpine - # ports: - # volumes: - # - ./data/influxdb:xxx - # - ## TODO: yeah gonna need a better name here - # influx: - # build: ./src/influxdb/ - # image: influx:0.2.0 - # depends_on: - # - ds18b20 - # - influxdb - # - zigbee - # - comfortstat - # ports: - # - 8870:8870 - # volumes: - # - ./data/influxdb:/data + influxdb: + image: influxdb:2.7.11-alpine + environment: + DOCKER_INFLUXDB_INIT_MODE: setup + DOCKER_INFLUXDB_INIT_USERNAME: admin + DOCKER_INFLUXDB_INIT_PASSWORD: password + DOCKER_INFLUXDB_INIT_ORG: organisation + DOCKER_INFLUXDB_INIT_BUCKET: arrowhead + INFLUXD_LOG_LEVEL: warn + ports: + - 8086:8086 + volumes: + - ./data/influxdb/data:/var/lib/influxdb2 + - ./data/influxdb/config:/etc/influxdb2 + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + + collector: + image: collector:0.1.0 + build: + context: ./src + args: + - SRC=./collector/*.go + - PORT=6666 + depends_on: + - ds18b20 + - comfortstat + - zigbee + - influxdb + network_mode: "host" + volumes: + - ./data/collector:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro diff --git a/go.mod b/go.mod index f731fe8..f17d075 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,19 @@ 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 + github.com/influxdata/influxdb-client-go/v2 v2.14.0 + github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 +) +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/oapi-codegen/runtime v1.0.0 // indirect + golang.org/x/net v0.23.0 // indirect +) + +// Replaces this library with a patched version replace github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 => github.com/lmas/mbaigo v0.0.0-20250123014631-ad869265483c diff --git a/go.sum b/go.sum index 674808d..e584cd1 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,31 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/lmas/mbaigo v0.0.0-20250123014631-ad869265483c h1:W+Jr5GQGKN4BiFOeAc6Uaq/Xc3k4/O5l+XzvsGlnlCQ= github.com/lmas/mbaigo v0.0.0-20250123014631-ad869265483c/go.mod h1:Bfx9Uj0uiTT7BCzzlImMiRd6vMoPQdsZIHGMQOVjx80= +github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= +github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=