diff --git a/App/.gitattributes b/App/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/App/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/App/.gitignore b/App/.gitignore new file mode 100644 index 0000000..4709183 --- /dev/null +++ b/App/.gitignore @@ -0,0 +1,2 @@ +# Godot 4+ specific ignores +.godot/ diff --git a/App/DesiredTempOverrideCheckButton.gd b/App/DesiredTempOverrideCheckButton.gd new file mode 100644 index 0000000..95cedba --- /dev/null +++ b/App/DesiredTempOverrideCheckButton.gd @@ -0,0 +1,16 @@ +extends CheckButton + + +# Called when the node enters the scene tree for the first time. +func _ready(): + pass # Replace with function body. + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta): + pass + +func _on_toggled(button_pressed): + if button_pressed: + !get_tree().current_sccene.override + print("Override:", get_tree().current_scene.override) diff --git a/App/Options.gd b/App/Options.gd new file mode 100644 index 0000000..fe968ad --- /dev/null +++ b/App/Options.gd @@ -0,0 +1,7 @@ +extends Control + +var override = false + +# Called when the node enters the scene tree for the first time. +func _ready(): + pass # Replace with function body. diff --git a/App/Options.tscn b/App/Options.tscn new file mode 100644 index 0000000..3ab5339 --- /dev/null +++ b/App/Options.tscn @@ -0,0 +1,55 @@ +[gd_scene load_steps=4 format=3 uid="uid://cwpvhktg0fqct"] + +[ext_resource type="Script" path="res://Options.gd" id="1_kdsdj"] +[ext_resource type="Script" path="res://OptionsBackButton.gd" id="2_06l4x"] +[ext_resource type="Script" path="res://DesiredTempOverrideCheckButton.gd" id="2_yph5t"] + +[node name="Options" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_kdsdj") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -50.0 +offset_top = -50.0 +offset_right = 50.0 +offset_bottom = 50.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="minTempLabel" type="Label" parent="VBoxContainer"] +layout_mode = 2 +text = "minTemp" + +[node name="minTempText" type="TextEdit" parent="VBoxContainer"] +custom_minimum_size = Vector2(100, 35) +layout_mode = 2 +placeholder_text = "min_Temp" + +[node name="DesiredTempOverrideCheckButton" type="CheckButton" parent="VBoxContainer"] +layout_mode = 2 +text = "DesiredTempOverride" +script = ExtResource("2_yph5t") + +[node name="OverrideDesiredTempTextbox" type="TextEdit" parent="VBoxContainer"] +custom_minimum_size = Vector2(100, 35) +layout_mode = 2 +placeholder_text = "Desired Temp" + +[node name="OptionsBackButton" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Back" +script = ExtResource("2_06l4x") + +[connection signal="toggled" from="VBoxContainer/DesiredTempOverrideCheckButton" to="VBoxContainer/DesiredTempOverrideCheckButton" method="_on_toggled"] +[connection signal="button_down" from="VBoxContainer/OptionsBackButton" to="VBoxContainer/OptionsBackButton" method="_on_button_down"] diff --git a/App/OptionsBackButton.gd b/App/OptionsBackButton.gd new file mode 100644 index 0000000..b711de9 --- /dev/null +++ b/App/OptionsBackButton.gd @@ -0,0 +1,15 @@ +extends Button + + +# Called when the node enters the scene tree for the first time. +func _ready(): + pass # Replace with function body. + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta): + pass + + +func _on_button_down(): + get_tree().change_scene_to_file("res://menu.tscn") diff --git a/App/OptionsButton.gd b/App/OptionsButton.gd new file mode 100644 index 0000000..75e82b4 --- /dev/null +++ b/App/OptionsButton.gd @@ -0,0 +1,11 @@ +extends Button + + +# Called when the node enters the scene tree for the first time. +func _ready(): + pass # Replace with function body. + + + +func _on_button_down(): + get_tree().change_scene_to_file("res://Options.tscn") diff --git a/App/QuitButton.gd b/App/QuitButton.gd new file mode 100644 index 0000000..24db399 --- /dev/null +++ b/App/QuitButton.gd @@ -0,0 +1,11 @@ +extends Button + + +# Called when the node enters the scene tree for the first time. +func _ready(): + pass # Replace with function body. + + + +func _on_button_down(): + get_tree().quit() diff --git a/App/StartButton.gd b/App/StartButton.gd new file mode 100644 index 0000000..9d69daf --- /dev/null +++ b/App/StartButton.gd @@ -0,0 +1,10 @@ +extends Button + + +# Called when the node enters the scene tree for the first time. +func _ready(): + pass # Replace with function body. + +func _on_button_down(): + print("Starting programs") + diff --git a/App/icon.svg b/App/icon.svg new file mode 100644 index 0000000..b370ceb --- /dev/null +++ b/App/icon.svg @@ -0,0 +1 @@ + diff --git a/App/icon.svg.import b/App/icon.svg.import new file mode 100644 index 0000000..cf857bc --- /dev/null +++ b/App/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://40v5s6011q63" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/App/menu.gd b/App/menu.gd new file mode 100644 index 0000000..7d054b3 --- /dev/null +++ b/App/menu.gd @@ -0,0 +1,6 @@ +extends Control + +# Called when the node enters the scene tree for the first time. + +func _ready(): + pass # Replace with function body. diff --git a/App/menu.tscn b/App/menu.tscn new file mode 100644 index 0000000..113ac7d --- /dev/null +++ b/App/menu.tscn @@ -0,0 +1,48 @@ +[gd_scene load_steps=5 format=3 uid="uid://dvpcro0lol46r"] + +[ext_resource type="Script" path="res://menu.gd" id="1_2af8p"] +[ext_resource type="Script" path="res://StartButton.gd" id="1_6edej"] +[ext_resource type="Script" path="res://QuitButton.gd" id="2_cawfr"] +[ext_resource type="Script" path="res://OptionsButton.gd" id="2_tp1y0"] + +[node name="Menu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 +script = ExtResource("1_2af8p") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 14 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_top = -50.5 +offset_bottom = 50.5 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="StartButton" type="Button" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 0 +text = "Start" +script = ExtResource("1_6edej") + +[node name="OptionsButton" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Options" +script = ExtResource("2_tp1y0") + +[node name="QuitButton" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Quit" +script = ExtResource("2_cawfr") + +[connection signal="button_down" from="VBoxContainer/StartButton" to="VBoxContainer/StartButton" method="_on_button_down"] +[connection signal="button_down" from="VBoxContainer/OptionsButton" to="VBoxContainer/OptionsButton" method="_on_button_down"] +[connection signal="button_down" from="VBoxContainer/QuitButton" to="VBoxContainer/QuitButton" method="_on_button_down"] diff --git a/App/project.godot b/App/project.godot new file mode 100644 index 0000000..fdbd8dc --- /dev/null +++ b/App/project.godot @@ -0,0 +1,15 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="project" +config/features=PackedStringArray("4.1", "Forward Plus") +config/icon="res://icon.svg" diff --git a/Comfortstat/Comfortstat.go b/Comfortstat/Comfortstat.go index abb2bb8..927cb99 100644 --- a/Comfortstat/Comfortstat.go +++ b/Comfortstat/Comfortstat.go @@ -84,7 +84,7 @@ func (t *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath t.httpSetSEKPrice(w, r) case "DesiredTemp": t.httpSetDesiredTemp(w, r) - case "userTemp": + case "UserTemp": t.httpSetUserTemp(w, r) case "Region": t.httpSetRegion(w, r) diff --git a/Makefile b/Makefile index 8597a08..2c9c053 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,7 @@ lint: # 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 +# Example installation (if you have rust installed): cargo install typos-cli spellcheck: typos . diff --git a/SunButton/SunButton.go b/SunButton/SunButton.go new file mode 100644 index 0000000..6f89252 --- /dev/null +++ b/SunButton/SunButton.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +func main() { + // Prepare for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) // Create a context that can be cancelled + defer cancel() // Make sure all paths cancel the context to avoid context leak + + // Instantiate the System + sys := components.NewSystem("SunButton", ctx) + + // Instantiate the Capsule + sys.Husk = &components.Husk{ + Description: "Is a controller for a consumed button based on a consumed time of day. Powered by SunriseSunset.io", + Certificate: "ABCD", + Details: map[string][]string{"Developer": {"Arrowhead"}}, + ProtoPort: map[string]int{"https": 0, "http": 8770, "coap": 0}, + InfoLink: "https://github.com/lmas/d0020e_code/tree/master/SunButton", + } + + // Instantiate a template unit asset + assetTemplate := initTemplate() + assetName := assetTemplate.GetName() + sys.UAssets[assetName] = &assetTemplate + + // Configure the system + rawResources, servsTemp, err := usecases.Configure(&sys) + if err != nil { + log.Fatalf("Configuration error: %v\n", err) + } + sys.UAssets = make(map[string]*components.UnitAsset) // Clear the unit asset map (from the template) + for _, raw := range rawResources { + var uac UnitAsset + if err := json.Unmarshal(raw, &uac); err != nil { + log.Fatalf("Resource configuration error: %+v\n", err) + } + ua, startup := newUnitAsset(uac, &sys, servsTemp) + startup() + sys.UAssets[ua.GetName()] = &ua + } + + // Generate PKI keys and CSR to obtain a authentication certificate from the CA + usecases.RequestCertificate(&sys) + + // Register the (system) and its services + usecases.RegisterServices(&sys) + + // Start the http handler and server + go usecases.SetoutServers(&sys) + + // Wait for shutdown signal and gracefully close properly goroutines with context + <-sys.Sigs // Wait for a SIGINT (Crtl+C) signal + fmt.Println("\nShutting down system", sys.Name) + cancel() // Cancel the context, signaling the goroutines to stop + time.Sleep(2 * time.Second) // Allow the go routines to be executed, which might take more time then the main routine to end +} + +// Serving handles the resource services. NOTE: It expects those names from the request URL path +func (t *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) { + switch servicePath { + case "ButtonStatus": + t.httpSetButton(w, r) + case "Latitude": + t.httpSetLatitude(w, r) + case "Longitude": + t.httpSetLongitude(w, r) + default: + http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) + } +} + +// All these functions below handles HTTP "PUT" or "GET" requests to modify or retrieve the latitude and longitude and the state of the button +// For the PUT case - the "HTTPProcessSetRequest(w, r)" is called to prosses the data given from the user and if no error, +// call the set functions in thing.go with the value witch updates the value in the struct +func (rsc *UnitAsset) httpSetButton(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setButtonStatus(sig) + case "GET": + signalErr := rsc.getButtonStatus() + usecases.HTTPProcessGetRequest(w, r, &signalErr) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} + +func (rsc *UnitAsset) httpSetLatitude(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setLatitude(sig) + case "GET": + signalErr := rsc.getLatitude() + usecases.HTTPProcessGetRequest(w, r, &signalErr) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} + +func (rsc *UnitAsset) httpSetLongitude(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setLongitude(sig) + case "GET": + signalErr := rsc.getLongitude() + usecases.HTTPProcessGetRequest(w, r, &signalErr) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} diff --git a/SunButton/SunButton_test.go b/SunButton/SunButton_test.go new file mode 100644 index 0000000..8ca33bb --- /dev/null +++ b/SunButton/SunButton_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHttpSetButton(t *testing.T) { + ua := initTemplate().(*UnitAsset) + + // Good case test: GET + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", nil) + goodStatusCode := 200 + ua.httpSetButton(w, r) + // calls the method and extracts the response and save is in resp for the upcoming tests + resp := w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // this is a simple check if the JSON response contains the specific value/unit/version + value := strings.Contains(string(body), `"value": 0.5`) + unit := strings.Contains(string(body), `"unit": "bool"`) + version := strings.Contains(string(body), `"version": "SignalA_v1.0"`) + // check results from above + if value != true { + t.Errorf("expected the statement to be true!") + } + if unit != true { + t.Errorf("expected the unit statement to be true!") + } + if version != true { + t.Errorf("expected the version statement to be true!") + } + + //Godd test case: PUT + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody := bytes.NewReader([]byte(`{"value": 0, "unit": "bool", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", fakebody) // simulating a put request from a user to update the button status + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetButton(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + + //BAD case: PUT, if the fake body is formatted incorrectly + + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody = bytes.NewReader([]byte(`{"123, "unit": "bool", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", fakebody) // simulating a put request from a user to update the button status + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetButton(w, r) + // save the response and read the body + resp = w.Result() + if resp.StatusCode == goodStatusCode { + t.Errorf("expected bad status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + // Bad test case: default part of code + // force the case to hit default statement but alter the method + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://172.30.106.39:8670/SunButton/Button/ButtonStatus", nil) + // calls the method and extracts the response and save is in resp for the upcoming tests + ua.httpSetButton(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetLatitude(t *testing.T) { + ua := initTemplate().(*UnitAsset) + + //Godd test case: PUT + // creates a fake request body with JSON data + w := httptest.NewRecorder() + fakebody := bytes.NewReader([]byte(`{"value": 65.584816, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Latitude", fakebody) // simulating a put request from a user to update the latitude + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + goodStatusCode := 200 + ua.httpSetLatitude(w, r) + + // save the response and read the body + resp := w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + + //BAD case: PUT, if the fake body is formatted incorrectly + + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody = bytes.NewReader([]byte(`{"123, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Latitude", fakebody) // simulating a put request from a user to update the latitude + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetLatitude(w, r) + // save the response and read the body + resp = w.Result() + if resp.StatusCode == goodStatusCode { + t.Errorf("expected bad status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + //Good test case: GET + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://172.30.106.39:8670/SunButton/Button/Latitude", nil) + goodStatusCode = 200 + ua.httpSetLatitude(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // this is a simple check if the JSON response contains the specific value/unit/version + value := strings.Contains(string(body), `"value": 65.584816`) + unit := strings.Contains(string(body), `"unit": "Degrees"`) + version := strings.Contains(string(body), `"version": "SignalA_v1.0"`) + // check the result from above + if value != true { + t.Errorf("expected the statement to be true!") + } + if unit != true { + t.Errorf("expected the unit statement to be true!") + } + if version != true { + t.Errorf("expected the version statement to be true!") + } + // bad test case: default part of code + // force the case to hit default statement but alter the method + w = httptest.NewRecorder() + r = httptest.NewRequest("666", "http://172.30.106.39:8670/SunButton/Button/Latitude", nil) + ua.httpSetLatitude(w, r) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} + +func TestHttpSetLongitude(t *testing.T) { + ua := initTemplate().(*UnitAsset) + //Godd test case: PUT + + // creates a fake request body with JSON data + w := httptest.NewRecorder() + fakebody := bytes.NewReader([]byte(`{"value": 22.156704, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r := httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Longitude", fakebody) // simulating a put request from a user to update the longitude + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + goodStatusCode := 200 + ua.httpSetLongitude(w, r) + + // save the response and read the body + resp := w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + //BAD case: PUT, if the fake body is formatted incorrectly + + // creates a fake request body with JSON data + w = httptest.NewRecorder() + fakebody = bytes.NewReader([]byte(`{"123, "unit": "Degrees", "version": "SignalA_v1.0"}`)) // converts the Jason data so it can be read + r = httptest.NewRequest("PUT", "http://172.30.106.39:8670/SunButton/Button/Longitude", fakebody) // simulating a put request from a user to update the min temp + r.Header.Set("Content-Type", "application/json") // basic setup to prevent the request to be rejected. + ua.httpSetLongitude(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode == goodStatusCode { + t.Errorf("expected bad status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + //Good test case: GET + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://172.30.106.39:8670/SunButton/Button/Longitude", nil) + goodStatusCode = 200 + ua.httpSetLongitude(w, r) + + // save the response and read the body + resp = w.Result() + if resp.StatusCode != goodStatusCode { + t.Errorf("expected good status code: %v, got %v", goodStatusCode, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // this is a simple check if the JSON response contains the specific value/unit/version + value := strings.Contains(string(body), `"value": 22.156704`) + unit := strings.Contains(string(body), `"unit": "Degrees"`) + version := strings.Contains(string(body), `"version": "SignalA_v1.0"`) + if value != true { + t.Errorf("expected the statement to be true!") + } + if unit != true { + t.Errorf("expected the unit statement to be true!") + } + if version != true { + t.Errorf("expected the version statement to be true!") + } + // bad test case: default part of code + + // force the case to hit default statement but alter the method + w = httptest.NewRecorder() + r = httptest.NewRequest("666", "http://172.30.106.39:8670/SunButton/Button/Longitude", nil) + ua.httpSetLongitude(w, r) + //save the response + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected the status to be bad but got: %v", resp.StatusCode) + } +} diff --git a/SunButton/thing.go b/SunButton/thing.go new file mode 100644 index 0000000..f34fdf7 --- /dev/null +++ b/SunButton/thing.go @@ -0,0 +1,334 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/forms" + "github.com/sdoque/mbaigo/usecases" +) + +type SunData struct { + Date string `json:"date"` + Sunrise string `json:"sunrise"` + Sunset string `json:"sunset"` + First_light string `json:"first_light"` + Last_light string `json:"last_light"` + Dawn string `json:"dawn"` + Dusk string `json:"dusk"` + Solar_noon string `json:"solar_noon"` + Golden_hour string `json:"golden_hour"` + Day_length string `json:"day_length"` + Timezone string `json:"timezone"` + Utc_offset float64 `json:"utc_offset"` +} + +type Data struct { + Results SunData `json:"results"` + Status string `json:"status"` +} + +// A unitAsset models an interface or API for a smaller part of a whole system, for example a single temperature sensor. +// This type must implement the go interface of "components.UnitAsset" +type UnitAsset struct { + Name string `json:"name"` + Owner *components.System `json:"-"` + Details map[string][]string `json:"details"` + ServicesMap components.Services `json:"-"` + CervicesMap components.Cervices `json:"-"` + + Period time.Duration `json:"samplingPeriod"` + + ButtonStatus float64 `json:"ButtonStatus"` + Latitude float64 `json:"Latitude"` + oldLatitude float64 + Longitude float64 `json:"Longitude"` + oldLongitude float64 + data Data + connError float64 +} + +// GetName returns the name of the Resource. +func (ua *UnitAsset) GetName() string { + return ua.Name +} + +// GetServices returns the services of the Resource. +func (ua *UnitAsset) GetServices() components.Services { + return ua.ServicesMap +} + +// GetCervices returns the list of consumed services by the Resource. +func (ua *UnitAsset) GetCervices() components.Cervices { + return ua.CervicesMap +} + +// GetDetails returns the details of the Resource. +func (ua *UnitAsset) GetDetails() map[string][]string { + return ua.Details +} + +// Ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation) +var _ components.UnitAsset = (*UnitAsset)(nil) + +//////////////////////////////////////////////////////////////////////////////// + +// initTemplate initializes a new UA and prefils it with some default values. +// The returned instance is used for generating the configuration file, whenever it's missing. +// (see https://github.com/sdoque/mbaigo/blob/main/components/service.go for documentation) +func initTemplate() components.UnitAsset { + setLatitude := components.Service{ + Definition: "Latitude", + SubPath: "Latitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current set latitude (using a GET request)", + } + setLongitude := components.Service{ + Definition: "Longitude", + SubPath: "Longitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current set longitude (using a GET request)", + } + setButtonStatus := components.Service{ + Definition: "ButtonStatus", + SubPath: "ButtonStatus", + Details: map[string][]string{"Unit": {"bool"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the status of a button (using a GET request)", + } + + return &UnitAsset{ + // These fields should reflect a unique asset (ie, a single sensor with unique ID and location) + Name: "Button", + Details: map[string][]string{"Location": {"Kitchen"}}, + Latitude: 65.584816, // Latitude for the button + Longitude: 22.156704, // Longitude for the button + ButtonStatus: 0.5, // Status for the button (on/off) NOTE: This status is neither on or off as default, this is up for the system to decide. + Period: 15, + data: Data{SunData{}, ""}, + + // Maps the provided services from above + ServicesMap: components.Services{ + setLatitude.SubPath: &setLatitude, + setLongitude.SubPath: &setLongitude, + setButtonStatus.SubPath: &setButtonStatus, + }, + } +} + +//////////////////////////////////////////////////////////////////////////////// + +// newUnitAsset creates a new and proper instance of UnitAsset, using settings and +// values loaded from an existing configuration file. +// This function returns an UA instance that is ready to be published and used, +// aswell as a function that can perform any cleanup when the system is shutting down. +func newUnitAsset(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) { + sProtocol := components.SProtocols(sys.Husk.ProtoPort) + + // the Cervice that is to be consumed by the ZigBee, therefore the name with the C + t := &components.Cervice{ + Name: "state", + Protos: sProtocol, + Url: make([]string, 0), + } + + ua := &UnitAsset{ + // Filling in public fields using the given data + Name: uac.Name, + Owner: sys, + Details: uac.Details, + ServicesMap: components.CloneServices(servs), + Latitude: uac.Latitude, + Longitude: uac.Longitude, + ButtonStatus: uac.ButtonStatus, + Period: uac.Period, + data: uac.data, + CervicesMap: components.Cervices{ + t.Name: t, + }, + } + + var ref components.Service + for _, s := range servs { + if s.Definition == "ButtonStatus" { + ref = s + } + } + + ua.CervicesMap["state"].Details = components.MergeDetails(ua.Details, ref.Details) + + // Returns the loaded unit asset and a function to handle + return ua, func() { + // Start the unit asset(s) + go ua.feedbackLoop(sys.Ctx) + } +} + +// getLatitude is used for reading the current latitude +func (ua *UnitAsset) getLatitude() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.Latitude + f.Unit = "Degrees" + f.Timestamp = time.Now() + return f +} + +// setLatitude is used for updating the current latitude +func (ua *UnitAsset) setLatitude(f forms.SignalA_v1a) { + ua.oldLatitude = ua.Latitude + ua.Latitude = f.Value +} + +// getLongitude is used for reading the current longitude +func (ua *UnitAsset) getLongitude() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.Longitude + f.Unit = "Degrees" + f.Timestamp = time.Now() + return f +} + +// setLongitude is used for updating the current longitude +func (ua *UnitAsset) setLongitude(f forms.SignalA_v1a) { + ua.oldLongitude = ua.Longitude + ua.Longitude = f.Value +} + +// getButtonStatus is used for reading the current button status +func (ua *UnitAsset) getButtonStatus() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.ButtonStatus + f.Unit = "bool" + f.Timestamp = time.Now() + return f +} + +// setButtonStatus is used for updating the current button status +func (ua *UnitAsset) setButtonStatus(f forms.SignalA_v1a) { + ua.ButtonStatus = f.Value +} + +// feedbackLoop is THE control loop (IPR of the system) +func (ua *UnitAsset) feedbackLoop(ctx context.Context) { + // Initialize a ticker for periodic execution + ticker := time.NewTicker(ua.Period * time.Second) + defer ticker.Stop() + + // Start the control loop + for { + select { + case <-ticker.C: + ua.processFeedbackLoop() + case <-ctx.Done(): + return + } + } +} + +// This function sends a new button status to the ZigBee system if needed +func (ua *UnitAsset) processFeedbackLoop() { + date := time.Now().Format("2006-01-02") // Gets the current date in the defined format. + apiURL := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + + if !((ua.data.Results.Date == date) && ((ua.oldLatitude == ua.Latitude) && (ua.oldLongitude == ua.Longitude))) { // If there is a new day or latitude or longitude is changed new data is downloaded. + log.Printf("Sun API has not been called today for this region, downloading sun data...") + err := ua.getAPIData(apiURL) + if err != nil { + log.Printf("Cannot get sun API data: %s\n", err) + return + } + } + ua.oldLongitude = ua.Longitude + ua.oldLatitude = ua.Latitude + layout := "15:04:05" + sunrise, _ := time.Parse(layout, ua.data.Results.Sunrise) // Saves the sunrise in the layout format. + sunset, _ := time.Parse(layout, ua.data.Results.Sunset) // Saves the sunset in the layout format. + currentTime, _ := time.Parse(layout, time.Now().Local().Format("15:04:05")) // Saves the current time in the layout format. + if currentTime.After(sunrise) && !(currentTime.After(sunset)) { // This checks if the time is between sunrise or sunset, if it is the switch is supposed to turn off. + if ua.ButtonStatus == 0 && ua.connError == 0 { // If the button is already off there is no need to send a state again. + log.Printf("The button is already off") + return + } + ua.ButtonStatus = 0 + err := ua.sendStatus() + if err != nil { + ua.connError = 1 + return + } else { + ua.connError = 0 + } + + } else { // If the time is not between sunrise and sunset the button is supposed to be on. + if ua.ButtonStatus == 1 && ua.connError == 0 { // If the button is already on there is no need to send a state again. + log.Printf("The button is already on") + return + } + ua.ButtonStatus = 1 + err := ua.sendStatus() + if err != nil { + ua.connError = 1 + return + } else { + ua.connError = 0 + } + } +} + +func (ua *UnitAsset) sendStatus() error { + // Prepare the form to send + var of forms.SignalA_v1a + of.NewForm() + of.Value = ua.ButtonStatus + of.Unit = ua.CervicesMap["state"].Details["Unit"][0] + of.Timestamp = time.Now() + // Pack the new state form + // Pack() converting the data in "of" into JSON format + op, err := usecases.Pack(&of, "application/json") + if err != nil { + return err + } + // Send the new request + err = usecases.SetState(ua.CervicesMap["state"], ua.Owner, op) + if err != nil { + log.Printf("Cannot update ZigBee state: %s\n", err) + return err + } + return nil +} + +var errStatuscode error = fmt.Errorf("bad status code") + +func (ua *UnitAsset) getAPIData(apiURL string) error { + //apiURL := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + parsedURL, err := url.Parse(apiURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return errors.New("the url is invalid") + } + // End of validating the URL // + res, err := http.Get(parsedURL.String()) + if err != nil { + return err + } + body, err := io.ReadAll(res.Body) // Read the payload into body variable + if err != nil { + return err + } + err = json.Unmarshal(body, &ua.data) + + defer res.Body.Close() + + if res.StatusCode > 299 { + return errStatuscode + } + if err != nil { + return err + } + return nil +} diff --git a/SunButton/thing_test.go b/SunButton/thing_test.go new file mode 100644 index 0000000..ee152d9 --- /dev/null +++ b/SunButton/thing_test.go @@ -0,0 +1,340 @@ +package main + +import ( + "context" + "fmt" + "io" + "strings" + + //"io" + "net/http" + //"strings" + "testing" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/forms" +) + +// mockTransport is used for replacing the default network Transport (used by +// http.DefaultClient) and it will intercept network requests. + +type mockTransport struct { + resp *http.Response + hits map[string]int +} + +func newMockTransport(resp *http.Response) mockTransport { + t := mockTransport{ + resp: resp, + hits: make(map[string]int), + } + // Hijack the default http client so no actual http requests are sent over the network + http.DefaultClient.Transport = t + return t +} + +// domainHits returns the number of requests to a domain (or -1 if domain wasn't found). +func (t mockTransport) domainHits(domain string) int { + for u, hits := range t.hits { + if u == domain { + return hits + } + } + return -1 +} + +var sunDataExample string = fmt.Sprintf(`[{ + "results": { + "date": "%d-%02d-%02d", + "sunrise": "08:00:00", + "sunset": "20:00:00", + "first_light": "07:00:00", + "last_light": "21:00:00", + "dawn": "07:30:00", + "dusk": "20:30:00", + "solar_noon": "16:00:00", + "golden_hour": "19:00:00", + "day_length": "12:00:00" + "timezone": "CET", + "utc_offset": "1" + }, + "status": "OK" +}]`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + +// RoundTrip method is required to fulfil the RoundTripper interface (as required by the DefaultClient). +// It prevents the request from being sent over the network and count how many times +// a domain was requested. +func (t mockTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + t.hits[req.URL.Hostname()] += 1 + t.resp.Request = req + return t.resp, nil +} + +// ////////////////////////////////////////////////////////////////////////////// +const apiDomain string = "https://sunrisesunset.io/" + +func TestSingleUnitAssetOneAPICall(t *testing.T) { + ua := initTemplate().(*UnitAsset) + //maby better to use a getter method + url := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + //Body: io.NopCloser(strings.NewReader(fakeBody)), + } + trans := newMockTransport(resp) + // Creates a single UnitAsset and assert it only sends a single API request + + //retrieveAPIPrice(ua) + // better to use a getter method?? + ua.getAPIData(url) + + // TEST CASE: cause a single API request + hits := trans.domainHits(apiDomain) + if hits > 1 { + t.Errorf("expected number of api requests = 1, got %d requests", hits) + } +} + +func TestSetMethods(t *testing.T) { + asset := initTemplate().(*UnitAsset) + + // Simulate the input signals + buttonStatus := forms.SignalA_v1a{ + Value: 0, + } + //call and test ButtonStatus + asset.setButtonStatus(buttonStatus) + if asset.ButtonStatus != 0 { + t.Errorf("expected ButtonStatus to be 0, got %f", asset.ButtonStatus) + } + // Simulate the input signals + latitude := forms.SignalA_v1a{ + Value: 65.584816, + } + // call and test Latitude + asset.setLatitude(latitude) + if asset.Latitude != 65.584816 { + t.Errorf("expected Latitude to be 65.584816, got %f", asset.Latitude) + } + // Simulate the input signals + longitude := forms.SignalA_v1a{ + Value: 22.156704, + } + //call and test MinPrice + asset.setLongitude(longitude) + if asset.Longitude != 22.156704 { + t.Errorf("expected Longitude to be 22.156704, got %f", asset.Longitude) + } +} + +func TestGetMethods(t *testing.T) { + uasset := initTemplate().(*UnitAsset) + + // ButtonStatus + // check if the value from the struct is the actual value that the func is getting + result1 := uasset.getButtonStatus() + if result1.Value != uasset.ButtonStatus { + t.Errorf("expected Value of the ButtonStatus is to be %v, got %v", uasset.ButtonStatus, result1.Value) + } + //check that the Unit is correct + if result1.Unit != "bool" { + t.Errorf("expected Unit to be 'bool', got %v", result1.Unit) + } + // Latitude + // check if the value from the struct is the actual value that the func is getting + result2 := uasset.getLatitude() + if result2.Value != uasset.Latitude { + t.Errorf("expected Value of the Latitude is to be %v, got %v", uasset.Latitude, result2.Value) + } + //check that the Unit is correct + if result2.Unit != "Degrees" { + t.Errorf("expected Unit to be 'Degrees', got %v", result2.Unit) + } + // Longitude + // check if the value from the struct is the actual value that the func is getting + result3 := uasset.getLongitude() + if result3.Value != uasset.Longitude { + t.Errorf("expected Value of the Longitude is to be %v, got %v", uasset.Longitude, result3.Value) + } + //check that the Unit is correct + if result3.Unit != "Degrees" { + t.Errorf("expected Unit to be 'Degrees', got %v", result3.Unit) + } +} + +func TestInitTemplate(t *testing.T) { + uasset := initTemplate().(*UnitAsset) + + //// unnecessary test, but good for practicing + name := uasset.GetName() + if name != "Button" { + t.Errorf("expected name of the resource is %v, got %v", uasset.Name, name) + } + Services := uasset.GetServices() + if Services == nil { + t.Fatalf("If Services is nil, not worth to continue testing") + } + // Services + if Services["ButtonStatus"].Definition != "ButtonStatus" { + t.Errorf("expected service definition to be ButtonStatus") + } + if Services["Latitude"].Definition != "Latitude" { + t.Errorf("expected service definition to be Latitude") + } + if Services["Longitude"].Definition != "Longitude" { + t.Errorf("expected service definition to be Longitude") + } + //GetCervice// + Cervices := uasset.GetCervices() + if Cervices != nil { + t.Fatalf("If cervises not nil, not worth to continue testing") + } + //Testing Details// + Details := uasset.GetDetails() + if Details == nil { + t.Errorf("expected a map, but Details was nil, ") + } +} + +func TestNewUnitAsset(t *testing.T) { + // prepare for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled + defer cancel() // make sure all paths cancel the context to avoid context leak + // instantiate the System + sys := components.NewSystem("SunButton", ctx) + // Instantiate the Capsule + sys.Husk = &components.Husk{ + Description: " is a controller for a consumed smart plug based on status depending on the sun", + Certificate: "ABCD", + Details: map[string][]string{"Developer": {"Arrowhead"}}, + ProtoPort: map[string]int{"https": 0, "http": 8670, "coap": 0}, + InfoLink: "https://github.com/lmas/d0020e_code/tree/Comfortstat/SunButton", + } + setButtonStatus := components.Service{ + Definition: "ButtonStatus", + SubPath: "ButtonStatus", + Details: map[string][]string{"Unit": {"bool"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current button status (using a GET request)", + } + setLatitude := components.Service{ + Definition: "Latitude", + SubPath: "Latitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the latitude (using a GET request)", + } + setLongitude := components.Service{ + Definition: "Longitude", + SubPath: "Longitude", + Details: map[string][]string{"Unit": {"Degrees"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the longitude (using a GET request)", + } + // New UnitAsset struct init + uac := UnitAsset{ + //These fields should reflect a unique asset (ie, a single sensor with unique ID and location) + Name: "Button", + Details: map[string][]string{"Location": {"Kitchen"}}, + ButtonStatus: 0.5, + Latitude: 65.584816, + Longitude: 22.156704, + Period: 15, + data: Data{SunData{}, ""}, + + // Maps the provided services from above + ServicesMap: components.Services{ + setButtonStatus.SubPath: &setButtonStatus, + setLatitude.SubPath: &setLatitude, + setLongitude.SubPath: &setLongitude, + }, + } + + ua, _ := newUnitAsset(uac, &sys, nil) + // Calls the method that gets the name of the new UnitAsset + name := ua.GetName() + if name != "Button" { + t.Errorf("expected name to be Button, but got: %v", name) + } +} + +// Functions that help creating bad body +type errReader int + +var errBodyRead error = fmt.Errorf("bad body read") + +func (errReader) Read(p []byte) (n int, err error) { + return 0, errBodyRead +} + +func (errReader) Close() error { + return nil +} + +// cretas a URL that is broken +var brokenURL string = string([]byte{0x7f}) + +func TestGetAPIPriceDataSun(t *testing.T) { + ua := initTemplate().(*UnitAsset) + // Should not be an array, it should match the exact struct + sunDataExample = fmt.Sprintf(`{ + "results": { + "date": "%d-%02d-%02d", + "sunrise": "08:00:00", + "sunset": "20:00:00", + "first_light": "07:00:00", + "last_light": "21:00:00", + "dawn": "07:30:00", + "dusk": "20:30:00", + "solar_noon": "16:00:00", + "golden_hour": "19:00:00", + "day_length": "12:00:00", + "timezone": "CET", + "utc_offset": 1 + }, + "status": "OK" + }`, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day(), + ) + // creates a fake response + fakeBody := fmt.Sprintf(sunDataExample) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + // Testing good cases + // Test case: goal is no errors + apiURL := fmt.Sprintf(`http://api.sunrisesunset.io/json?lat=%06f&lng=%06f&timezone=CET&date=%d-%02d-%02d&time_format=24`, ua.Latitude, ua.Longitude, time.Now().Local().Year(), int(time.Now().Local().Month()), time.Now().Local().Day()) + fmt.Println("API URL:", apiURL) + + // creates a mock HTTP transport to simulate api response for the test + newMockTransport(resp) + err := ua.getAPIData(apiURL) + if err != nil { + t.Errorf("expected no errors but got %s :", err) + } + // Testing bad cases + // Test case: using wrong url leads to an error + newMockTransport(resp) + // Call the function (which now hits the mock server) + err = ua.getAPIData(brokenURL) + if err == nil { + t.Errorf("Expected an error but got none!") + } + // Test case: if reading the body causes an error + resp.Body = errReader(0) + newMockTransport(resp) + err = ua.getAPIData(apiURL) + if err != errBodyRead { + t.Errorf("expected an error %v, got %v", errBodyRead, err) + } + //Test case: if status code > 299 + resp.Body = io.NopCloser(strings.NewReader(fakeBody)) + resp.StatusCode = 300 + newMockTransport(resp) + err = ua.getAPIData(apiURL) + // check the statuscode is bad, witch is expected for the test to be successful + if err != errStatuscode { + t.Errorf("expected an bad status code but got %v", err) + } +} diff --git a/ZigBeeHandler/ZigBeeHandler.go b/ZigBeeHandler/ZigBeeHandler.go new file mode 100644 index 0000000..f03cba5 --- /dev/null +++ b/ZigBeeHandler/ZigBeeHandler.go @@ -0,0 +1,244 @@ +/* In order to follow the structure of the other systems made before this one, most functions and structs are copied and slightly edited from: + * https://github.com/sdoque/systems/blob/main/thermostat/thermostat.go */ + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +func main() { + // prepare for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled + defer cancel() // make sure all paths cancel the context to avoid context leak + + // instantiate the System + sys := components.NewSystem("ZigBeeHandler", ctx) + + // Instantiate the Capsule + sys.Husk = &components.Husk{ + 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}, + InfoLink: "https://github.com/sdoque/systems/tree/master", + } + + // instantiate a template unit asset + assetTemplate := initTemplate() + 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 { + log.Fatalf("Configuration error: %v\n", err) + } + sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template) + for _, raw := range rawResources { + var uac UnitAsset + if err := json.Unmarshal(raw, &uac); err != nil { + log.Fatalf("Resource configuration error: %+v\n", err) + } + ua, startup := newResource(uac, &sys, servsTemp) + if err := startup(); err != nil { + log.Fatalf("Error during startup: %s\n", err) + } + sys.UAssets[ua.GetName()] = &ua + } + + // Generate PKI keys and CSR to obtain a authentication certificate from the CA + usecases.RequestCertificate(&sys) + + // Register the (system) and its services + usecases.RegisterServices(&sys) + + // start the http handler and server + go usecases.SetoutServers(&sys) + + // wait for shutdown signal, and gracefully close properly goroutines with context + <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal + fmt.Println("\nshuting down system", sys.Name) + cancel() // cancel the context, signaling the goroutines to stop + time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end +} + +// 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) + case "consumption": + t.consumption(w, r) + case "current": + t.current(w, r) + case "power": + t.power(w, r) + case "voltage": + t.voltage(w, r) + case "state": + t.state(w, r) + default: + http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) + } +} + +// TODO: Add webhandler for power plug controller (sun up/down) and/or schedule later on. +// STRETCH GOAL: Instead of looking for specific models types, add a list of supported devices that we can check against + +// Function used by webhandler to either get or set the setpoint of a specific device +func (rsc *UnitAsset) setpt(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with setpoints actually support the http get method + if rsc.Model == "ZHAThermostat" || rsc.Model == "Smart plug" { + setPointForm := rsc.getSetPoint() + usecases.HTTPProcessGetRequest(w, r, &setPointForm) + return + } + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + case "PUT": + // Make sure only devices with setpoints actually support the http put method + if rsc.Model == "ZHAThermostat" || rsc.Model == "Smart plug" { + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "Request incorrectly formatted", http.StatusBadRequest) + return + } + rsc.setSetPoint(sig) + return + } + http.Error(w, "This device doesn't support that method.", http.StatusInternalServerError) + return + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} + +// Function used by the webhandler to get the consumption of a device +func (rsc *UnitAsset) consumption(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with consumption sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + consumptionForm, err := rsc.getConsumption() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &consumptionForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +// Function used by the webhandler to get the power of a device +func (rsc *UnitAsset) power(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with power sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + powerForm, err := rsc.getPower() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &powerForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +// Function used by the webhandler to get the current of a device +func (rsc *UnitAsset) current(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with current sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + currentForm, err := rsc.getCurrent() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, ¤tForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +// Function used by the webhandler to get the voltage of a device +func (rsc *UnitAsset) voltage(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Make sure only devices with voltage sensors actually support the http get method + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + voltageForm, err := rsc.getVoltage() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &voltageForm) + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} + +func (rsc *UnitAsset) state(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + stateForm, err := rsc.getState() + if err != nil { + http.Error(w, "Failed getting data, or data not present", http.StatusInternalServerError) + return + } + usecases.HTTPProcessGetRequest(w, r, &stateForm) + case "PUT": + if rsc.Model != "Smart plug" { + http.Error(w, "That device doesn't support that method.", http.StatusInternalServerError) + return + } + sig, err := usecases.HTTPProcessSetRequest(w, r) + if err != nil { + http.Error(w, "Request incorrectly formatted", http.StatusBadRequest) + return + } + err = rsc.setState(sig) + if err != nil { + http.Error(w, "Something went wrong when setting state", http.StatusBadRequest) + return + } + default: + http.Error(w, "Method is not supported", http.StatusNotFound) + } +} diff --git a/ZigBeeHandler/thing.go b/ZigBeeHandler/thing.go new file mode 100644 index 0000000..bfb1ebc --- /dev/null +++ b/ZigBeeHandler/thing.go @@ -0,0 +1,792 @@ +/* In order to follow the structure of the other systems made before this one, most functions and structs are copied and slightly edited from: + * https://github.com/sdoque/systems/blob/main/thermostat/thing.go */ + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/forms" + "github.com/sdoque/mbaigo/usecases" +) + +// ------------------------------------ Used when discovering the gateway +type discoverJSON struct { + Id string `json:"id"` + Internalipaddress string `json:"internalipaddress"` + Macaddress string `json:"macaddress"` + Internalport int `json:"internalport"` + Name string `json:"name"` + Publicipaddress string `json:"publicipaddress"` +} + +//-------------------------------------Define the unit asset + +// UnitAsset type models the unit asset (interface) of the system +type UnitAsset struct { + Name string `json:"name"` + Owner *components.System `json:"-"` + Details map[string][]string `json:"details"` + ServicesMap components.Services `json:"-"` + CervicesMap components.Cervices `json:"-"` + // + Model string `json:"model"` + Uniqueid string `json:"uniqueid"` + Period time.Duration `json:"period"` + Setpt float64 `json:"setpoint"` + Slaves map[string]string `json:"slaves"` + Apikey string `json:"APIkey"` +} + +// GetName returns the name of the Resource. +func (ua *UnitAsset) GetName() string { + return ua.Name +} + +// GetServices returns the services of the Resource. +func (ua *UnitAsset) GetServices() components.Services { + return ua.ServicesMap +} + +// GetCervices returns the list of consumed services by the Resource. +func (ua *UnitAsset) GetCervices() components.Cervices { + return ua.CervicesMap +} + +// GetDetails returns the details of the Resource. +func (ua *UnitAsset) GetDetails() map[string][]string { + return ua.Details +} + +// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation) +var _ components.UnitAsset = (*UnitAsset)(nil) + +//-------------------------------------Instantiate a unit asset template + +// initTemplate initializes a UnitAsset with default values. +func initTemplate() components.UnitAsset { + // This service will only be supported by Smart Thermostats and Smart Power plugs. + setPointService := components.Service{ + Definition: "setpoint", + SubPath: "setpoint", + Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current thermal setpoint (GET) or sets it (PUT)", + } + + // This service will only be supported by Smart Power plugs (will be noted as sensors of type ZHAConsumption) + consumptionService := components.Service{ + Definition: "consumption", + SubPath: "consumption", + Details: map[string][]string{"Unit": {"Wh"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current consumption of the device in Wh (GET)", + } + + // This service will only be supported by Smart Power plugs (will be noted as sensors of type ZHAPower) + currentService := components.Service{ + Definition: "current", + SubPath: "current", + Details: map[string][]string{"Unit": {"mA"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current going through the device in mA (GET)", + } + + // This service will only be supported by Smart Power plugs (will be noted as sensors of type ZHAPower) + powerService := components.Service{ + Definition: "power", + SubPath: "power", + Details: map[string][]string{"Unit": {"W"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current consumption of the device in W (GET)", + } + + // This service will only be supported by Smart Power plugs (Will be noted as sensors of type ZHAPower) + voltageService := components.Service{ + Definition: "voltage", + SubPath: "voltage", + Details: map[string][]string{"Unit": {"V"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current voltage of the device in V (GET)", + } + + // This service will only be supported by Smart Power plugs (Will be noted as sensors of type ZHAPower) + stateService := components.Service{ + Definition: "state", + SubPath: "state", + Details: map[string][]string{"Unit": {"Binary"}, "Forms": {"SignalA_v1a"}}, + Description: "provides the current state of the device (GET), or sets it (PUT) [0 = off, 1 = on]", + } + + // var uat components.UnitAsset // this is an interface, which we then initialize + uat := &UnitAsset{ + Name: "SmartThermostat1", + Details: map[string][]string{"Location": {"Kitchen"}}, + Model: "ZHAThermostat", + Uniqueid: "14:ef:14:10:00:6f:d0:d7-11-1201", + Period: 10, + Setpt: 20, + // Only switches needs to manually add controlled power plug and light uniqueids, power plugs get their sensors added automatically + Slaves: map[string]string{}, + Apikey: "1234", + ServicesMap: components.Services{ + setPointService.SubPath: &setPointService, + consumptionService.SubPath: &consumptionService, + currentService.SubPath: ¤tService, + powerService.SubPath: &powerService, + voltageService.SubPath: &voltageService, + stateService.SubPath: &stateService, + }, + } + return uat +} + +//-------------------------------------Instantiate the unit assets based on configuration + +// 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() error) { + sProtocols := components.SProtocols(sys.Husk.ProtoPort) + + // instantiate the consumed services + t := &components.Cervice{ + Name: "temperature", + Protos: sProtocols, + Url: make([]string, 0), + } + // instantiate the unit asset + ua := &UnitAsset{ + Name: uac.Name, + Owner: sys, + Details: uac.Details, + ServicesMap: components.CloneServices(servs), + Model: uac.Model, + Uniqueid: uac.Uniqueid, + Period: uac.Period, + Setpt: uac.Setpt, + Slaves: uac.Slaves, + Apikey: uac.Apikey, + CervicesMap: components.Cervices{ + t.Name: t, + }, + } + + // Handles a panic caused by when this field is missing from the config file + if uac.Slaves == nil { + ua.Slaves = make(map[string]string) + } + + var ref components.Service + for _, s := range servs { + if s.Definition == "setpoint" { + ref = s + } + } + ua.CervicesMap["temperature"].Details = components.MergeDetails(ua.Details, ref.Details) + return ua, ua.startup +} + +func (ua *UnitAsset) startup() (err error) { + if websocketport == "startup" { + err = ua.getWebsocketPort() + if err != nil { + err = fmt.Errorf("getwebsocketport: %w", err) + return + } + } + + switch ua.Model { + case "ZHAThermostat": + err = ua.sendSetPoint() + if err != nil { + err = fmt.Errorf("ZHAThermostat sendsetpoint: %w", err) + return + } + + case "Smart plug": + // Find all sensors belonging to the smart plug and put them in the slaves array with + // their type as the key + err = ua.getSensors() + if err != nil { + err = fmt.Errorf("SmartPlug getsensors: %w", err) + return + } + // Not all smart plugs should be handled by the feedbackloop, some should be handled by a switch + if ua.Period > 0 { + // start the unit assets feedbackloop, this fetches the temperature from ds18b20 and and toggles + // between on/off depending on temperature in the room and a set temperature in the unitasset + go ua.feedbackLoop(ua.Owner.Ctx) + } + + case "ZHASwitch": + // Starts listening to the websocket to find buttonevents (button presses) and then + // turns its controlled devices (slaves) on/off + go ua.initWebsocketClient(ua.Owner.Ctx) + } + return +} + +func (ua *UnitAsset) feedbackLoop(ctx context.Context) { + // Initialize a ticker for periodic execution + ticker := time.NewTicker(ua.Period * time.Second) + defer ticker.Stop() + // start the control loop + for { + select { + case <-ticker.C: + ua.processFeedbackLoop() + case <-ctx.Done(): + return + } + } +} + +func (ua *UnitAsset) processFeedbackLoop() { + // get the current temperature + tf, err := usecases.GetState(ua.CervicesMap["temperature"], ua.Owner) + if err != nil { + 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 { + err = ua.toggleState(true) + if err != nil { + log.Println("Error occurred while toggling state to true: ", err) + } + } else { + err = ua.toggleState(false) + if err != nil { + log.Println("Error occurred while toggling state to false: ", err) + } + } +} + +var gateway string + +const discoveryURL string = "https://phoscon.de/discover" + +var errBadFormValue error = fmt.Errorf("bad form value") +var errStatusCode error = fmt.Errorf("bad status code") +var errMissingGateway error = fmt.Errorf("missing gateway") +var errMissingUniqueID error = fmt.Errorf("uniqueid not found") + +// Function to find the gateway and save its ip and port (assuming there's only one) and return the error if one occurs +func findGateway() (err error) { + // https://pkg.go.dev/net/http#Get + // GET https://phoscon.de/discover // to find gateways, array of JSONs is returned in http body, we'll only have one so take index 0 + // 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(discoveryURL) + if err != nil { + return + } + defer res.Body.Close() + if res.StatusCode > 299 { + return errStatusCode + } + body, err := io.ReadAll(res.Body) // Read the payload into body variable + if err != nil { + 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 { + return + } + // If the returned list is empty, return a missing gateway error + if len(gw) < 1 { + return errMissingGateway + } + // Save the gateway + s := fmt.Sprintf(`%s:%d`, gw[0].Internalipaddress, gw[0].Internalport) + gateway = s + return +} + +//-------------------------------------Thing's resource methods + +// Function to get sensors connected to a smart plug and place them in the "slaves" array +type sensorJSON struct { + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getSensors() (err error) { + // Create and send a get request to get all sensors connected to deConz gateway + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors" + req, err := createGetRequest(apiURL) + if err != nil { + return err + } + data, err := sendGetRequest(req) + if err != nil { + return err + } + // Unmarshal data from get request into an easy to use JSON format + var sensors map[string]sensorJSON + err = json.Unmarshal(data, &sensors) + if err != nil { + return err + } + // Take only the part of the mac address that is present in both the smart plug and the sensors + macAddr := ua.Uniqueid[0:23] + for _, sensor := range sensors { + uniqueid := sensor.UniqueID + check := strings.Contains(uniqueid, macAddr) + if check == true { + if sensor.Type == "ZHAConsumption" { + ua.Slaves["ZHAConsumption"] = sensor.UniqueID + } + if sensor.Type == "ZHAPower" { + ua.Slaves["ZHAPower"] = sensor.UniqueID + } + } + } + return +} + +// getSetPoint fills out a signal form with the current thermal setpoint +func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) { + f.NewForm() + f.Value = ua.Setpt + f.Unit = "Celsius" + f.Timestamp = time.Now() + return f +} + +// setSetPoint updates the thermal setpoint +func (ua *UnitAsset) setSetPoint(f forms.SignalA_v1a) { + ua.Setpt = f.Value +} + +// Function to send a new setpoint of a device that has the "heatsetpoint" in its +// config (smart plug or smart thermostat) +func (ua *UnitAsset) sendSetPoint() (err error) { + // API call to set desired temp in smart thermostat, PUT call should be sent + // to URL/api/apikey/sensors/sensor_id/config + // --- Send setpoint to specific unit --- + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Uniqueid + "/config" + // Create http friendly payload + s := fmt.Sprintf(`{"heatsetpoint":%f}`, ua.Setpt*100) // Create payload + req, err := createPutRequest(s, apiURL) + if err != nil { + return + } + return sendPutRequest(req) +} + +// Functions and structs to get and set current state of a smart plug/light +type plugJSON struct { + State struct { + On bool `json:"on"` + } `json:"state"` +} + +func (ua *UnitAsset) getState() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/lights/" + ua.Uniqueid + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + data, err := sendGetRequest(req) + var plug plugJSON + err = json.Unmarshal(data, &plug) + if err != nil { + return f, err + } + // Return a form containing current state in binary form (1 = on, 0 = off) + if plug.State.On == true { + f := getForm(1, "Binary") + return f, nil + } else { + f := getForm(0, "Binary") + return f, nil + } +} + +func (ua *UnitAsset) setState(f forms.SignalA_v1a) (err error) { + if f.Value == 0 { + return ua.toggleState(false) + } + if f.Value == 1 { + return ua.toggleState(true) + } + return errBadFormValue +} + +// Function to toggle the state of a specific device (power plug or light) on/off and return an error if it occurs +func (ua *UnitAsset) toggleState(state bool) (err error) { + // API call to toggle light/smart plug on/off, PUT call should be sent to URL/api/apikey/lights/[light_id or plug_id]/state + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/lights/" + ua.Uniqueid + "/state" + // Create http friendly payload + s := fmt.Sprintf(`{"on":%t}`, state) // Create payload + req, err := createPutRequest(s, apiURL) + if err != nil { + return + } + return sendPutRequest(req) +} + +// Functions to create put or get request and return the *http.request and/or error if one occurs +func createPutRequest(data string, apiURL string) (req *http.Request, err error) { + body := bytes.NewReader([]byte(data)) // Put data into buffer + req, err = http.NewRequest(http.MethodPut, apiURL, body) // Put request is made + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") // Make sure it's JSON + return req, nil +} + +func createGetRequest(apiURL string) (req *http.Request, err error) { + req, err = http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") // Make sure it's JSON + return req, nil +} + +// A function to send a put request that returns the error if one occurs +func sendPutRequest(req *http.Request) (err error) { + resp, err := http.DefaultClient.Do(req) // Perform the http request + if err != nil { + return err + } + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes + if err != nil { + return + } + if resp.StatusCode > 299 { + return errStatusCode + } + return +} + +// A function to send get requests and return the data received in the response body as a []byte and/or error if it happens +func sendGetRequest(req *http.Request) (data []byte, err error) { + resp, err := http.DefaultClient.Do(req) // Perform the http request + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err = io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes + if err != nil { + return nil, err + } + if resp.StatusCode > 299 { + return nil, errStatusCode + } + return data, nil +} + +// Creates a form that fills the fields of forms.SignalA_v1a with values from arguments and current time +func getForm(value float64, unit string) (f forms.SignalA_v1a) { + f.NewForm() + f.Value = value + f.Unit = fmt.Sprint(unit) + f.Timestamp = time.Now() + return f +} + +// ------------------------------------------------------------------------------------------------------------ +// IMPORTANT: lumi.plug.maeu01 HAS BEEN KNOWN TO GIVE BAD READINGS, BASICALLY STOP RESPONDING OR RESPOND WITH 0 +// They also don't appear for a long time after re-pairing devices to deConz +// ------------------------------------------------------------------------------------------------------------ + +// Struct and method to get and return a form containing current consumption (in Wh) +type consumptionJSON struct { + State struct { + Consumption uint64 `json:"consumption"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getConsumption() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAConsumption"] + // Create a get request + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + // Perform get request to sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) + if err != nil { + return f, err + } + // Unmarshal the body into usable json data + var data consumptionJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err + } + // Set form value to sensors value + value := float64(data.State.Consumption) + f = getForm(value, "Wh") + return f, nil +} + +// Struct and method to get and return a form containing current power (in W) +type powerJSON struct { + State struct { + Power int16 `json:"power"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getPower() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAPower"] + // Create a get request + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + // Perform get request to sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) + if err != nil { + return f, err + } + // Unmarshal the body into usable json data + var data powerJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err + } + // Set form value to sensors value + value := float64(data.State.Power) + f = getForm(value, "W") + return f, nil +} + +// Struct and method to get and return a form containing current (in mA) +type currentJSON struct { + State struct { + Current uint16 `json:"current"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getCurrent() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAPower"] + // Create a get request + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + // Perform get request to sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) + if err != nil { + return f, err + } + // Unmarshal the body into usable json data + var data currentJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err + } + // Set form value to sensors value + value := float64(data.State.Current) + f = getForm(value, "mA") + return f, nil +} + +// Struct and method to get and return a form containing current voltage (in V) +type voltageJSON struct { + State struct { + Voltage uint16 `json:"voltage"` + } `json:"state"` + Name string `json:"name"` + UniqueID string `json:"uniqueid"` + Type string `json:"type"` +} + +func (ua *UnitAsset) getVoltage() (f forms.SignalA_v1a, err error) { + apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Slaves["ZHAPower"] + // Create a get request + req, err := createGetRequest(apiURL) + if err != nil { + return f, err + } + // Perform get request to power plug sensor, expecting a body containing json data to be returned + body, err := sendGetRequest(req) + if err != nil { + return f, err + } + // Unmarshal the body into usable json data + var data voltageJSON + err = json.Unmarshal(body, &data) + if err != nil { + return f, err + } + // Set form value to sensors value + value := float64(data.State.Voltage) + f = getForm(value, "V") + return f, nil +} + +// --- HOW TO CONNECT AND LISTEN TO A WEBSOCKET --- +// Port 443, can be found by curl -v "http://localhost:8080/api/[apikey]/config", and getting the "websocketport". +// 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://github.com/gorilla/websocket + +// In order for websocketport to run at startup i gave it something to check against and update +var websocketport = "startup" + +type eventJSON struct { + State struct { + Buttonevent int `json:"buttonevent"` + } `json:"state"` + UniqueID string `json:"uniqueid"` +} + +// This function sends a request for the config of the gateway, and saves the websocket port +// If an error occurs it will return that error +func (ua *UnitAsset) getWebsocketPort() (err error) { + // --- Get config --- + apiURL := fmt.Sprintf("http://%s/api/%s/config", gateway, ua.Apikey) + // Create a new request (Get) + req, err := http.NewRequest(http.MethodGet, apiURL, nil) // Put request is made + if err != nil { + return err + } + // Make sure it's JSON + req.Header.Set("Content-Type", "application/json") + // Send the request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + // Read the response body, and check for errors/bad statuscodes + resBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode > 299 { + return errStatusCode + } + // How to access maps inside of maps below! + // https://stackoverflow.com/questions/28806951/accessing-nested-map-of-type-mapstringinterface-in-golang + var configMap map[string]interface{} + err = json.Unmarshal([]byte(resBody), &configMap) + if err != nil { + return err + } + websocketport = fmt.Sprint(configMap["websocketport"]) + return +} + +// STRETCH GOAL: Below can also be done with groups, could look into making groups for each switch, +// and then delete them on shutdown doing it with groups would make it so we don't +// have to keep track of a global variable and i think if unlucky only change one +// light or smart plug depending on reachability. Also first click currently always +// turn lights on, and then start working as intended. +// +// This function loops through the "slaves" of a unit asset, and sets them to either +// true (for on) and false (off), returning an error if it occurs. +func (ua *UnitAsset) toggleSlaves(currentState bool) (err error) { + var req *http.Request + for i := range ua.Slaves { + // API call to toggle smart plug or lights on/off, PUT call should be sent + // to URL/api/apikey/[sensors or lights]/sensor_id/config + apiURL := fmt.Sprintf("http://%s/api/%s/lights/%v/state", gateway, ua.Apikey, ua.Slaves[i]) + // Create http friendly payload + s := fmt.Sprintf(`{"on":%t}`, currentState) + req, err = createPutRequest(s, apiURL) + if err != nil { + return + } + if err = sendPutRequest(req); err != nil { + return + } + } + return +} + +// Function starts listening to a websocket, every message received through websocket is read, +// and checked if it's what we're looking for. +// The uniqueid (UniqueID in systemconfig.json file) from the connected switch is used to filter out messages +func (ua *UnitAsset) initWebsocketClient(ctx context.Context) { + dialer := websocket.Dialer{} + wsURL := fmt.Sprintf("ws://localhost:%s", websocketport) + conn, _, err := dialer.Dial(wsURL, nil) + if err != nil { + log.Fatal("Error occurred while dialing websocket:", err) + return + } + defer conn.Close() + currentState := false + + for { + select { + case <-ctx.Done(): // Shutdown + return + default: + // Read the message + // TODO: this is a blocking call! Might need to handle this read better, + // otherwise this goroutine might never be shutdown (from the context). + _, b, err := conn.ReadMessage() + if err != nil { + log.Println("Error occurred while reading message:", err) + return + } + currentState, err = ua.handleWebSocketMsg(currentState, b) + if err != nil { + log.Printf("Error handling websocket message: %s", err) + } + } + } +} + +func (ua *UnitAsset) handleWebSocketMsg(currentState bool, body []byte) (newState bool, err error) { + // Put it into a message variable of type eventJSON with "buttonevent" easily accessible + newState = currentState + var message eventJSON + err = json.Unmarshal(body, &message) + if err != nil { + err = fmt.Errorf("unmarshall message: %w", err) + return + } + + if message.UniqueID == ua.Uniqueid { + // Depending on what buttonevent occurred, either turn the slaves on, or off + switch message.State.Buttonevent { + case 1002: // toggle the smart plugs/lights (lights) + newState = !currentState // Toggles the state between true/false + err = ua.toggleSlaves(newState) + if err != nil { + err = fmt.Errorf("toggle slaves to state %v: %w", newState, err) + return + } + + case 2002: + // TODO: Find out how "long presses" works and if it can be used through websocket + + default: + // Ignore any other events + } + } + return +} diff --git a/ZigBeeHandler/thing_test.go b/ZigBeeHandler/thing_test.go new file mode 100644 index 0000000..2d70b3b --- /dev/null +++ b/ZigBeeHandler/thing_test.go @@ -0,0 +1,1049 @@ +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 { + 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 +} + +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) + } +} + +var brokenURL string = string([]byte{0x7f}) + +func TestToggleState(t *testing.T) { + // Create mock response and unitasset for toggleState() function + fakeBody := fmt.Sprint(`{"on":true, "Version": "SignalA_v1a"}`) + 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" +} + +func TestCreatePutRequest(t *testing.T) { + // Setup + data := "test" + apiURL := "http://localhost:8080/test" + + // --- Good test case: createPutRequest() --- + raw, err := createPutRequest(data, apiURL) + if err != nil { + t.Error("Error occurred, expected none") + } + body, err := io.ReadAll(raw.Body) + if string(body) != "test" { + t.Error("Error because body should be 'test', was: ", string(body)) + } + + // --- Bad test case: Error in createPutRequest() because of broken URL--- + raw, err = createPutRequest(data, brokenURL) + if err == nil { + t.Error("Expected error because of broken URL") + } +} + +func TestCreateGetRequest(t *testing.T) { + // Setup + apiURL := "http://localhost:8080/test" + + // --- Good test case: createGetRequest() --- + _, err := createGetRequest(apiURL) + if err != nil { + t.Error("Error occurred, expected none") + } + + // --- Bad test case: Error in createGetRequest() because of broken URL--- + _, err = createGetRequest(brokenURL) + if err == nil { + t.Error("Expected error because of broken URL") + } +} + +func TestSendPutRequests(t *testing.T) { + // Set up standard response & catch http requests + fakeBody := fmt.Sprint(`Test`) + apiURL := "http://localhost:8080/test" + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + + // --- Good test case: sendPutRequest --- + newMockTransport(resp, false, nil) + s := fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload + req, _ := createPutRequest(s, apiURL) + err := sendPutRequest(req) + if err != nil { + t.Error("Expected no errors, error occurred:", err) + } + + // Break defaultClient.Do() + // --- Error performing request --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + s = fmt.Sprintf(`{"heatsetpoint":%f}`, 25.0) // Create payload + req, _ = createPutRequest(s, apiURL) + err = sendPutRequest(req) + if err == nil { + t.Error("Error expected while performing http request, got nil instead") + } + + // Error unpacking body + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + + err = sendPutRequest(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 = sendPutRequest(req) + if err != errStatusCode { + t.Error("Expected errStatusCode, got", err) + } +} + +func TestSendGetRequest(t *testing.T) { + fakeBody := fmt.Sprint(`Test ok`) + apiURL := "http://localhost:8080/test" + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(fakeBody)), + } + + // --- Good test case: sendGetRequest --- + newMockTransport(resp, false, nil) + req, _ := createGetRequest(apiURL) + raw, err := sendGetRequest(req) + if err != nil { + t.Error("Expected no errors, error occurred:", err) + } + data := string(raw) + if data != "Test ok" { + t.Error("Expected returned body to be 'Test ok', was: ", data) + } + + // Break defaultClient.Do() + // --- Error performing request --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + req, _ = createGetRequest(apiURL) + raw, err = sendGetRequest(req) + if err == nil { + t.Error("Error expected while performing http request, got nil instead") + } + + // Error unpacking body + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + req, _ = createGetRequest(apiURL) + raw, err = sendGetRequest(req) + if err == nil { + t.Error("Expected errors, no error occurred:") + } + + // Error StatusCode + resp.Body = io.NopCloser(strings.NewReader(fakeBody)) + resp.StatusCode = 300 + newMockTransport(resp, false, nil) + req, _ = createGetRequest(apiURL) + raw, err = sendGetRequest(req) + if err != errStatusCode { + t.Error("Expected errStatusCode, got", err) + } +} + +func TestGetSensors(t *testing.T) { + // Setup for test + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "54:ef:44:10:00:d8:82:8d-01" + + zBeeResponse := `{ + "1": { + "state": {"consumption": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-02-000c", + "type": "ZHAConsumption" + }, + "2": { + "state": {"power": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-03-000c", + "type": "ZHAPower" + }}` + + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + + // --- Good case test--- + newMockTransport(zResp, false, nil) + ua.getSensors() + if ua.Slaves["ZHAConsumption"] != "54:ef:44:10:00:d8:82:8d-02-000c" { + t.Errorf("Error with ZHAConsumption, wrong mac addr.") + } + if ua.Slaves["ZHAPower"] != "54:ef:44:10:00:d8:82:8d-03-000c" { + t.Errorf("Error with ZHAPower, wrong mac addr.") + } + + // --- Bad case: Error on createGetRequest() using brokenURL (bad character) --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + err := ua.getSensors() + if err == nil { + t.Errorf("Expected an error during createGetRequest() because gateway is an invalid control char") + } + + // --- Bad case: Error while unmarshalling data --- + gateway = "localhost:8080" + FaultyzBeeResponse := `{ + "1": { + "state": {"consumption": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-02-000c"+123, + "type": "ZHAConsumption" + }, + "2": { + "state": {"power": 1}, + "name": "test consumption", + "uniqueid": "54:ef:44:10:00:d8:82:8d-03-000c"+123, + "type": "ZHAPower" + }}` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(FaultyzBeeResponse)), + } + newMockTransport(zResp, false, nil) + err = ua.getSensors() + if err == nil { + t.Errorf("Expected error while unmarshalling data because of broken uniqueid field") + } + + // --- Bad case: Error while sending request --- + newMockTransport(zResp, false, fmt.Errorf("Test error")) + err = ua.getSensors() + if err == nil { + t.Errorf("Expected error during sendGetRequest()") + } +} + +func TestGetState(t *testing.T) { + // Setup for test + ua := initTemplate().(*UnitAsset) + gateway = "localhost:8080" + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "54:ef:44:10:00:d8:82:8d-01" + zBeeResponseTrue := `{"state": {"on": true}}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponseTrue)), + } + // --- Good test case: plug.State.On = true --- + newMockTransport(zResp, false, nil) + f, err := ua.getState() + if f.Value != 1 { + t.Errorf("Expected value to be 1, was %f", f.Value) + } + if err != nil { + t.Errorf("Expected no errors got: %v", err) + } + + // --- Good test case: plug.State.On = false --- + zBeeResponseFalse := `{"state": {"on": false}}` + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponseFalse)) + newMockTransport(zResp, false, nil) + f, err = ua.getState() + + if f.Value != 0 { + t.Errorf("Expected value to be 0, was %f", f.Value) + } + + if err != nil { + t.Errorf("Expected no errors got: %v", err) + } + + // --- Bad test case: Error on createGetRequest() --- + gateway = brokenURL + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponseTrue)) + newMockTransport(zResp, false, nil) + f, err = ua.getState() + + if err == nil { + t.Errorf("Expected an error during createGetRequest() because gateway is an invalid control char") + } + + gateway = "localhost:8080" + + // --- Bad test case: Error on unmarshal --- + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getState() + + if err == nil { + t.Errorf("Expected an error while unmarshalling data") + } +} + +func TestSetState(t *testing.T) { + // Setup + gateway = "localhost:8080" + var f forms.SignalA_v1a + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "54:ef:44:10:00:d8:82:8d-01" + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("")), + } + // --- Good test case: f.Value = 1 --- + newMockTransport(zResp, false, nil) + f.NewForm() + f.Value = 1 + f.Timestamp = time.Now() + err := ua.setState(f) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + // --- Good test case: f.Value = 0 --- + newMockTransport(zResp, false, nil) + f.NewForm() + f.Value = 0 + f.Timestamp = time.Now() + err = ua.setState(f) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + // --- Bad test case: f.value is not 1 or 0 + newMockTransport(zResp, false, nil) + f.NewForm() + f.Value = 3 + f.Timestamp = time.Now() + err = ua.setState(f) + if err != errBadFormValue { + t.Errorf("Expected error because of f.Value not being 0 or 1") + } +} + +func TestGetConsumption(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAConsumption"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"consumption": 123}, "name": "consumptionTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getConsumption() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "Wh" { + t.Errorf("Expected unit to be Wh, was: %s", f.Unit) + } + + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getConsumption() + if err == nil { + t.Errorf("Expected errors but got none (broken url)") + } + + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getConsumption() + if err == nil { + t.Errorf("Expected errors but got none (errReader body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getConsumption() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } +} + +func TestGetPower(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getPower() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "W" { + t.Errorf("Expected unit to be W, was: %s", f.Unit) + } + + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getPower() + if err == nil { + t.Errorf("Expected errors but got none (broken url)") + } + + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getPower() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getPower() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } +} + +func TestGetCurrent(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"current": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getCurrent() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "mA" { + t.Errorf("Expected unit to be mA, was: %s", f.Unit) + } + + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getCurrent() + if err == nil { + t.Errorf("Expected errors but got none (broken url)") + } + + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getCurrent() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getCurrent() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } +} + +func TestGetVoltage(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-01" + zBody := `{"state": {"voltage": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBody)), + } + // --- Good test case: All ok --- + newMockTransport(zResp, false, nil) + f, err := ua.getVoltage() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if f.Value != 123 { + t.Errorf("Expected %f, got %f", 123.0, f.Value) + } + if f.Unit != "V" { + t.Errorf("Expected unit to be V, was: %s", f.Unit) + } + + // --- Bad test case: Breaking createGetRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(zResp, false, nil) + f, err = ua.getVoltage() + if err == nil { + t.Errorf("Expected errors but got none (broken url)") + } + + // --- Bad test case: Breaking sendGetRequest w/ errReader body --- + gateway = "localhost:8080" + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + f, err = ua.getVoltage() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } + + // --- Bad test case: Breaking Unmarshalling of data --- + gateway = "localhost:8080" + zBodyBroken := `{"state": {"power": 123}, "name": "powerTest", "uniqueid": "14:ef:14:10:00:b2:b2:89-XX-XXXX", "type": "Smart plug"}+123` + zResp.Body = io.NopCloser(strings.NewReader(zBodyBroken)) + newMockTransport(zResp, false, nil) + f, err = ua.getVoltage() + if err == nil { + t.Errorf("Expected errors but got none (broken body)") + } +} + +func TestGetWebsocketPort(t *testing.T) { + // Setup + gateway = "localhost:8080" + ua := initTemplate().(*UnitAsset) + ua.Name = "Switch1" + ua.Model = "ZHASwitch" + body := `{"test": "testing", "websocketport": "1010"}` + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + + // --- Good test case: all ok --- + newMockTransport(resp, false, nil) + websocketport = "test" + err := ua.getWebsocketPort() + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + if websocketport != "1010" { + t.Errorf("Expected websocketport to be 1010, was: %s", websocketport) + } + + // --- Bad test case: Breaking new get request w/ broken url --- + gateway = brokenURL + newMockTransport(resp, false, nil) + websocketport = "test" + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors while creating new get request") + } + gateway = "localhost:8080" + + // --- Bad test case: Breaking http.DefaultClient.do() --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + websocketport = "test" + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors while performing the http request") + } + + // --- Bad test case: bad body --- + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + websocketport = "test" + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors during io.ReadAll (error body)") + } + + // --- Bad test case: bad statuscode --- + resp.Body = io.NopCloser(strings.NewReader(body)) + newMockTransport(resp, false, nil) + websocketport = "test" + resp.StatusCode = 300 + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors during io.ReadAll (bad statuscode)") + } + + // --- Bad test case: Error unmarshalling body --- + badBody := `{"test": "testing", "websocketport": "1010"+123}` + resp.Body = io.NopCloser(strings.NewReader(badBody)) + newMockTransport(resp, false, nil) + websocketport = "test" + resp.StatusCode = 200 + err = ua.getWebsocketPort() + if err == nil { + t.Error("Expected errors during unmarshal") + } +} + +func TestToggleSlaves(t *testing.T) { + gateway = "localhost:8080" + websocketport = "443" + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Uniqueid = "14:ef:14:10:00:b2:b2:89-01" + ua.Slaves["ZHAConsumption"] = "14:ef:14:10:00:b2:b2:89-XX-XXX1" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b2:b2:89-XX-XXX2" + + // -- Good test case: all ok --- + body := `{"status": "testing ok"}` + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + newMockTransport(resp, false, nil) + err := ua.toggleSlaves(true) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + + // --- Bad test case: error during createPutRequest() w/ broken url --- + gateway = brokenURL + newMockTransport(resp, false, nil) + err = ua.toggleSlaves(true) + if err == nil { + t.Error("Expected error during createPutRequest (broken url)") + } + + // --- Bad test case: error during sendPutRequest() --- + gateway = "localhost:8080" + newMockTransport(resp, false, fmt.Errorf("Test error")) + ua.toggleSlaves(true) + if err == nil { + t.Error("Expected error during sendPutRequest") + } +} + +func TestHandleWebSocketMsg(t *testing.T) { + currentState := true + ua := initTemplate().(*UnitAsset) + ua.Name = "Switch1" + ua.Model = "ZHASwitch" + ua.Uniqueid = "14:ef:14:10:00:b2:b2:89-01" + ua.Slaves["Plug1"] = "34:ef:34:10:00:b2:b2:89-XX" + ua.Slaves["Plug2"] = "24:ef:24:10:00:b3:b3:89-XX" + message := []byte(`{"state": {"buttonevent": 1002}, "uniqueid": "14:ef:14:10:00:b2:b2:89-01"}`) + body := `{"status": "testing ok"}` + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + // --- Good test case: all ok --- + newMockTransport(resp, false, nil) + currentState, err := ua.handleWebSocketMsg(currentState, message) + if err != nil { + t.Errorf("Expected no errors, got: %v", err) + } + + // --- Bad test case: Unmarshal error --- + newMockTransport(resp, false, nil) + message = []byte(`{"state": {"buttonevent": 1002}, "uniqueid": "14:ef:14:10:00:b2:b2:89-01"}+123`) + currentState, err = ua.handleWebSocketMsg(currentState, message) + if err == nil { + t.Error("Expected errors during unmarshal, got none") + } + + // --- Bad test case: break toggleSlaves() --- + newMockTransport(resp, false, fmt.Errorf("Test error")) + message = []byte(`{"state": {"buttonevent": 1002}, "uniqueid": "14:ef:14:10:00:b2:b2:89-01"}`) + currentState, err = ua.handleWebSocketMsg(currentState, message) + if err == nil { + t.Error("Expected errors during unmarshal, got none") + } +} + +func TestStartup(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Model = "test" + websocketport = "startup" + body := `{"websocketport": "1010"}` + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + // --- Good test case: getWebsocketPort only runs if websocketport="startup" and model not present in switchcase --- + websocketport = "notstartup" + newMockTransport(resp, false, nil) + err := ua.startup() + if err != nil { + t.Errorf("Expected no errors, got %v", err) + } + + // --- Bad test case: getWebsocketPort returns error --- + websocketport = "startup" + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + err = ua.startup() + if err == nil { + t.Errorf("Expected errors during getWebsocketPort, got none") + } + + // --- Good test case: getWebsocketPort running runs --- + resp.Body = io.NopCloser(strings.NewReader(body)) + newMockTransport(resp, false, nil) + err = ua.startup() + if err != nil { + t.Errorf("Expected no errors, got %v", err) + } + + // --- Good test case: ZHAThermostat switch case --- + ua.Model = "ZHAThermostat" + body = `{"test": "test ok"}` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + newMockTransport(resp, false, nil) + err = ua.startup() + if err != nil { + t.Errorf("Expected no errors in ZHAThermostat switch case, got: %v", err) + } + + // --- Bad test case: "ZHAThermostat" switch case --- + ua.Model = "ZHAThermostat" + body = `{"test": "test ok"}` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + resp.Body = errReader(0) + newMockTransport(resp, false, nil) + err = ua.startup() + if err == nil { + t.Errorf(`Expected errors in "ZHAThermostat" switch case got none`) + } + + // --- Good test case: "Smart plug" switch case --- + ua.Model = "Smart plug" + ua.Period = -1 + body = `{"1": {"uniqueid": "ConsumptionTest", "type": "ZHAConsumption"}, "2": {"uniqueid": "PowerTest", "type": "ZHAPower"}}` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } + newMockTransport(resp, false, nil) + err = ua.startup() + if err != nil { + t.Errorf(`Expected no errors in "Smart plug" switch case, got: %v`, err) + } + + // --- Bad test case: "Smart plug" switch case --- + newMockTransport(resp, false, nil) + resp.Body = errReader(0) + err = ua.startup() + if err == nil { + t.Errorf(`Expected errors in "Smart plug" switch case`) + } +} diff --git a/ZigBeeHandler/zigbee_test.go b/ZigBeeHandler/zigbee_test.go new file mode 100644 index 0000000..1e07911 --- /dev/null +++ b/ZigBeeHandler/zigbee_test.go @@ -0,0 +1,622 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +var good_code = 200 + +func TestSetpt(t *testing.T) { + // --- ZHAThermostat --- + ua := initTemplate().(*UnitAsset) + + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", nil) + r.Header.Set("Content-Type", "application/json") + 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!") + } + // --- Good test case: not correct device type + ua.Model = "Wrong Device" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", nil) + ua.setpt(w, r) + // Read response and check statuscode + resp = w.Result() + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("Expected the status to be 500 but got: %v", resp.StatusCode) + } + + // --- Default part of code (faulty http method) --- + ua = initTemplate().(*UnitAsset) + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartThermostat1/setpoint", nil) + r.Header.Set("Content-Type", "application/json") + ua.setpt(w, r) + // Read response and check statuscode, expecting 404 (StatusNotFound) + resp = w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected the status to be bad but got: %v", resp.StatusCode) + } + + // --- 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/ZigBeeHandler/SmartThermostat1/setpoint", sentBody) + r.Header.Set("Content-Type", "application/json") + // Mock the http response/traffic to zigbee + zBeeResponse := `[{"success":{"/sensors/7/config/heatsetpoint":2400}}]` + resp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(resp, false, nil) + // Set the response body to same as mock response + w.Body = bytes.NewBuffer([]byte(zBeeResponse)) + // Send the request + ua.setpt(w, r) + resp = w.Result() + // Check for errors + if resp.StatusCode != good_code { + t.Errorf("Good PUT: Expected good status code: %v, got %v", good_code, resp.StatusCode) + } + // Convert body to a string and check that it's correct + respBodyBytes, _ := io.ReadAll(resp.Body) + respBody := string(respBodyBytes) + if respBody != `[{"success":{"/sensors/7/config/heatsetpoint":2400}}]` { + t.Errorf("Wrong body") + } + + // --- Bad test case: PUT Failing @ HTTPProcessSetRequest --- + 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/ZigBeeHandler/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") + } + + // --- Bad PUT (Cant reach ZigBee) --- + w = httptest.NewRecorder() + ua.Model = "Wrong device" + // 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/ZigBeeHandler/SmartThermostat1/setpoint", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.setpt(w, r) + resp = w.Result() + // Check for errors, should not be 200 + if resp.StatusCode == good_code { + t.Errorf("Bad PUT: Expected bad status code: got %v.", resp.StatusCode) + } +} + +func TestConsumption(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAConsumption"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + + zBeeResponse := `{ + "state": {"consumption": 1}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAConsumption" + }` + + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(zResp, false, nil) + ua.consumption(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() + if resp.StatusCode != good_code { + t.Errorf("expected good status code: %v, got %v", good_code, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + stringBody := string(body) + // Check if correct values are present in the body, each line returns true/false + value := strings.Contains(string(stringBody), `"value": 1`) + unit := strings.Contains(string(stringBody), `"unit": "Wh"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.consumption(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + // --- Bad test case: error from getConsumption() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SnartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.consumption(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + ua.consumption(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } +} + +func TestPower(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + + zBeeResponse := `{ + "state": {"power": 2}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(zResp, false, nil) + ua.power(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() + if resp.StatusCode != good_code { + t.Errorf("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": 2`) + unit := strings.Contains(string(stringBody), `"unit": "W"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.power(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + + // --- Bad test case: error from getPower() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SnartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + newMockTransport(zResp, false, nil) + ua.power(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + ua.power(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } +} + +func TestCurrent(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/current", nil) + + zBeeResponse := `{ + "state": {"current": 3}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(zResp, false, nil) + ua.current(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() + if resp.StatusCode != good_code { + t.Errorf("expected good status code: %v, got %v", good_code, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + stringBody := string(body) + // Check if correct values are present in the body, each line returns true/false + value := strings.Contains(string(stringBody), `"value": 3`) + unit := strings.Contains(string(stringBody), `"unit": "mA"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/consumption", nil) + newMockTransport(zResp, false, nil) + ua.current(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + + // --- Bad test case: error from getPower() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SnartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + newMockTransport(zResp, false, nil) + ua.current(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/power", nil) + ua.current(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } +} + +func TestVoltage(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + ua.Slaves["ZHAPower"] = "14:ef:14:10:00:b3:b3:89-01" + // --- Good case test: GET --- + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + zBeeResponse := `{ + "state": {"voltage": 4}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + newMockTransport(zResp, false, nil) + ua.voltage(w, r) + // Read response to a string, and save it in stringBody + resp := w.Result() + if resp.StatusCode != good_code { + t.Errorf("expected good status code: %v, got %v", good_code, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + stringBody := string(body) + // Check if correct values are present in the body, each line returns true/false + value := strings.Contains(string(stringBody), `"value": 4`) + unit := strings.Contains(string(stringBody), `"unit": "V"`) + version := strings.Contains(string(stringBody), `"version": "SignalA_v1.0"`) + // Check that above statements are true + if value != true { + t.Errorf("Good GET: The value statement should be true!") + } + if unit != true { + t.Errorf("Good GET: Expected the unit statement to be true!") + } + if version != true { + t.Errorf("Good GET: Expected the version statement to be true!") + } + + // --- Wrong model --- + ua.Model = "Wrong model" + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + newMockTransport(zResp, false, nil) + ua.voltage(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected statuscode 500, got: %d", resp.StatusCode) + } + + // --- Bad test case: error from getPower() because of broken body --- + ua.Model = "Smart plug" + zBeeResponse = `{ + "state": {"consumption": 1}, + "name": "SmartPlug1", + "uniqueid": "ConsumptionTest", + "type": "ZHAConsumption" + } + 123` + zResp = &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + newMockTransport(zResp, false, nil) + ua.voltage(w, r) + resp = w.Result() + if resp.StatusCode != 500 { + t.Errorf("Expected status code 500, got %d", resp.StatusCode) + } + + // --- Default part of code (Method not supported) + ua.Model = "Smart plug" + w = httptest.NewRecorder() + r = httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/voltage", nil) + ua.voltage(w, r) + resp = w.Result() + if resp.StatusCode != 404 { + t.Errorf("Expected statuscode to be 404, got %d", resp.StatusCode) + } + +} + +func TestState(t *testing.T) { + ua := initTemplate().(*UnitAsset) + ua.Name = "SmartPlug1" + ua.Model = "Smart plug" + + zBeeResponse := `{ + "state": {"on": true}, + "name": "SmartPlug1", + "uniqueid": "14:ef:14:10:00:b3:b3:89-XX-XXXX", + "type": "ZHAPower" + }` + zResp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(zBeeResponse)), + } + // --- Default part of code --- + newMockTransport(zResp, false, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("123", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.state(w, r) + res := w.Result() + _, err := io.ReadAll(res.Body) + if err != nil { + t.Error("Expected no errors") + } + if res.StatusCode != 404 { + t.Errorf("Expected no errors in default part of code, got: %d", res.StatusCode) + } + + // --- Good test case: GET --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.state(w, r) + res = w.Result() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Error("Expected no errors reading body") + } + stringBody := string(body) + value := strings.Contains(string(stringBody), `"value": 1`) + unit := strings.Contains(string(stringBody), `"unit": "Binary"`) + if value == false { + t.Error("Expected value to be 1, but wasn't") + } + if unit == false { + t.Error("Expected unit to be Binary, was something else") + } + + // --- Bad test case: GET Wrong model --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Wrong model" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 500 { + t.Errorf("Expected status code 500 w/ wrong model, was: %d", res.StatusCode) + } + + // --- Bad test case: GET Error from getState() --- + zResp.Body = errReader(0) + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", nil) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 500 { + t.Errorf("Expected status code 500 w/ error from getState(), was: %d", res.StatusCode) + } + + // --- Good test case: PUT --- + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponse)) + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody := `{"value": 0, "version": "SignalA_v1.0"}` + sentBody := io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 200 { + t.Errorf("Expected status code 200, was: %d", res.StatusCode) + } + + // --- Bad test case: PUT Wrong model --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody = `{"value": 0, "version": "SignalA_v1.0"}` + sentBody = io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Wrong model" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 500 { + t.Errorf("Expected status code 500, was: %d", res.StatusCode) + } + + // --- Bad test case: PUT Incorrectly formatted form --- + zResp.Body = io.NopCloser(strings.NewReader(zBeeResponse)) + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody = `{"value": a}` + sentBody = io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 400 { + t.Errorf("Expected status code to be 400, was %d", res.StatusCode) + } + + // --- Bad test case: PUT breaking setState() --- + newMockTransport(zResp, false, nil) + w = httptest.NewRecorder() + fakebody = `{"value": 3, "version": "SignalA_v1.0"}` // Value 3 not supported + sentBody = io.NopCloser(strings.NewReader(fakebody)) + r = httptest.NewRequest("PUT", "http://localhost:8870/ZigBeeHandler/SmartPlug1/state", sentBody) + r.Header.Set("Content-Type", "application/json") + ua.Model = "Smart plug" + ua.state(w, r) + res = w.Result() + if res.StatusCode != 400 { + t.Errorf("Expected status code to be 400, was %d", res.StatusCode) + } +} diff --git a/ZigBeeValve/ZigBeeValve.go b/ZigBeeValve/ZigBeeValve.go deleted file mode 100644 index d0cd301..0000000 --- a/ZigBeeValve/ZigBeeValve.go +++ /dev/null @@ -1,111 +0,0 @@ -/* In order to follow the structure of the other systems made before this one, most functions and structs are copied and slightly edited from: - * https://github.com/sdoque/systems/blob/main/thermostat/thermostat.go */ - -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "time" - - "github.com/sdoque/mbaigo/components" - "github.com/sdoque/mbaigo/usecases" -) - -func main() { - // prepare for graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled - defer cancel() // make sure all paths cancel the context to avoid context leak - - // instantiate the System - sys := components.NewSystem("ZigBeeHandler", ctx) - - // Instantiate the Capsule - sys.Husk = &components.Husk{ - 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}, - InfoLink: "https://github.com/sdoque/systems/tree/master/ZigBeeValve", - } - - // instantiate a template unit asset - assetTemplate := initTemplate() - 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 { - log.Fatalf("Configuration error: %v\n", err) - } - sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template) - for _, raw := range rawResources { - var uac UnitAsset - if err := json.Unmarshal(raw, &uac); err != nil { - log.Fatalf("Resource configuration error: %+v\n", err) - } - ua, startup := newResource(uac, &sys, servsTemp) - startup() - sys.UAssets[ua.GetName()] = &ua - } - - // Generate PKI keys and CSR to obtain a authentication certificate from the CA - usecases.RequestCertificate(&sys) - - // Register the (system) and its services - usecases.RegisterServices(&sys) - - // start the http handler and server - go usecases.SetoutServers(&sys) - - // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop - time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end -} - -// 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 configuration file]", http.StatusBadRequest) - } -} - -func (rsc *UnitAsset) setpt(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "GET": - setPointForm := rsc.getSetPoint() - usecases.HTTPProcessGetRequest(w, r, &setPointForm) - case "PUT": - sig, err := usecases.HTTPProcessSetRequest(w, r) - if err != nil { - http.Error(w, "Request incorrectly formatted", http.StatusBadRequest) - return - } - - rsc.setSetPoint(sig) - 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 deleted file mode 100644 index 8ab8268..0000000 --- a/ZigBeeValve/thing.go +++ /dev/null @@ -1,408 +0,0 @@ -/* In order to follow the structure of the other systems made before this one, most functions and structs are copied and slightly edited from: - * https://github.com/sdoque/systems/blob/main/thermostat/thing.go */ - -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "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 -type discoverJSON struct { - Id string `json:"id"` - Internalipaddress string `json:"internalipaddress"` - Macaddress string `json:"macaddress"` - Internalport int `json:"internalport"` - Name string `json:"name"` - Publicipaddress string `json:"publicipaddress"` -} - -//-------------------------------------Define the unit asset - -// UnitAsset type models the unit asset (interface) of the system -type UnitAsset struct { - Name string `json:"name"` - Owner *components.System `json:"-"` - Details map[string][]string `json:"details"` - ServicesMap components.Services `json:"-"` - CervicesMap components.Cervices `json:"-"` - // - 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. -func (ua *UnitAsset) GetName() string { - return ua.Name -} - -// GetServices returns the services of the Resource. -func (ua *UnitAsset) GetServices() components.Services { - return ua.ServicesMap -} - -// GetCervices returns the list of consumed services by the Resource. -func (ua *UnitAsset) GetCervices() components.Cervices { - return ua.CervicesMap -} - -// GetDetails returns the details of the Resource. -func (ua *UnitAsset) GetDetails() map[string][]string { - return ua.Details -} - -// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation) -var _ components.UnitAsset = (*UnitAsset)(nil) - -//-------------------------------------Instantiate a unit asset template - -// initTemplate initializes a UnitAsset with default values. -func initTemplate() components.UnitAsset { - setPointService := components.Service{ - Definition: "setpoint", - SubPath: "setpoint", - Details: map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the current thermal setpoint (GET) or sets it (PUT)", - } - /* - consumptionService := components.Service{ - Definition: "consumption", - SubPath: "consumption", - Details: map[string][]string{"Unit": {"Wh"}, "Forms": {"SignalA_v1a"}}, - Description: "provides the current consumption of the device (GET)", - } - */ - // var uat components.UnitAsset // this is an interface, which we then initialize - uat := &UnitAsset{ - Name: "SmartThermostat1", - Details: map[string][]string{"Location": {"Kitchen"}}, - Model: "ZHAThermostat", - Uniqueid: "14:ef:14:10:00:6f:d0:d7-11-1201", - deviceIndex: "", - Period: 10, - Setpt: 20, - Apikey: "1234", - ServicesMap: components.Services{ - setPointService.SubPath: &setPointService, - }, - } - return uat -} - -//-------------------------------------Instantiate the unit assets based on configuration - -// newResource creates the resource with its pointers and channels based on the configuration using the tConfig structs -// This is a startup function that's used to initiate the unit assets declared in the systemconfig.json, the function -// that is returned is later used to send a setpoint/start a goroutine depending on model of the unitasset -func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) { - // determine the protocols that the system supports - sProtocols := components.SProtocols(sys.Husk.ProtoPort) - - // instantiate the consumed services - t := &components.Cervice{ - Name: "temperature", - Protos: sProtocols, - Url: make([]string, 0), - } - // 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, - Apikey: uac.Apikey, - CervicesMap: components.Cervices{ - t.Name: t, - }, - } - var ref components.Service - for _, s := range servs { - if s.Definition == "setpoint" { - ref = s - } - } - ua.CervicesMap["temperature"].Details = components.MergeDetails(ua.Details, ref.Details) - - return ua, func() { - if ua.Model == "ZHAThermostat" { - /* - // Get correct index in list returned by api/sensors to make sure we always change correct device - err := ua.getConnectedUnits("sensors") - if err != nil { - log.Println("Error occurred during startup, while calling getConnectedUnits:", err) - } - */ - err := ua.sendSetPoint() - if err != nil { - log.Println("Error occurred during startup, while calling sendSetPoint():", err) - // TODO: Turn off system if this startup() fails? - } - } else if ua.Model == "Smart plug" { - /* - // Get correct index in list returned by api/lights to make sure we always change correct device - err := ua.getConnectedUnits("lights") - if err != nil { - log.Println("Error occurred during startup, while calling getConnectedUnits:", err) - } - */ - // Not all smart plugs should be handled by the feedbackloop, some should be handled by a switch - if ua.Period != 0 { - // start the unit assets feedbackloop, this fetches the temperature from ds18b20 and and toggles - // between on/off depending on temperature in the room and a set temperature in the unitasset - go ua.feedbackLoop(ua.Owner.Ctx) - } - } - } -} - -func (ua *UnitAsset) feedbackLoop(ctx context.Context) { - // Initialize a ticker for periodic execution - ticker := time.NewTicker(ua.Period * time.Second) - defer ticker.Stop() - // start the control loop - for { - select { - case <-ticker.C: - ua.processFeedbackLoop() - case <-ctx.Done(): - return - } - } -} - -func (ua *UnitAsset) processFeedbackLoop() { - // get the current temperature - tf, err := usecases.GetState(ua.CervicesMap["temperature"], ua.Owner) - if err != nil { - 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 { - err = ua.toggleState(true) - if err != nil { - log.Println("Error occurred while toggling state to true: ", err) - } - } else { - err = ua.toggleState(false) - if err != nil { - log.Println("Error occurred while toggling state to false: ", err) - } - } -} - -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(discoveryURL) - if err != nil { - return - } - defer res.Body.Close() - if res.StatusCode > 299 { - return errStatusCode - } - body, err := io.ReadAll(res.Body) // Read the payload into body variable - if err != nil { - 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 { - return - } - // If the returned list is empty, return a missing gateway error - if len(gw) < 1 { - return errMissingGateway - } - // Save the gateway - s := fmt.Sprintf(`%s:%d`, gw[0].Internalipaddress, gw[0].Internalport) - gateway = s - return -} - -//-------------------------------------Thing's resource methods - -// getSetPoint fills out a signal form with the current thermal setpoint -func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) { - f.NewForm() - f.Value = ua.Setpt - f.Unit = "Celsius" - f.Timestamp = time.Now() - return f -} - -// setSetPoint updates the thermal setpoint -func (ua *UnitAsset) setSetPoint(f forms.SignalA_v1a) { - ua.Setpt = f.Value -} - -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 - // --- Send setpoint to specific unit --- - apiURL := "http://" + gateway + "/api/" + ua.Apikey + "/sensors/" + ua.Uniqueid + "/config" - // Create http friendly payload - s := fmt.Sprintf(`{"heatsetpoint":%f}`, ua.Setpt*100) // Create payload - req, err := createRequest(s, apiURL) - if err != nil { - return - } - return sendRequest(req) -} - -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 - req, err := createRequest(s, apiURL) - if err != nil { - return - } - return sendRequest(req) -} - -// Useless function? Noticed uniqueid can be used as "id" to send requests instead of the index while testing, wasn't clear from documentation. Will need to test this more though -// TODO: Rewrite this to instead get the websocketport. -func (ua *UnitAsset) getConnectedUnits(unitType string) (err error) { - // --- Get all devices --- - apiURL := fmt.Sprintf("http://%s/api/%s/%s", gateway, ua.Apikey, unitType) - // Create a new request (Get) - // Put data into buffer - req, err := http.NewRequest(http.MethodGet, apiURL, nil) // Put request is made - req.Header.Set("Content-Type", "application/json") // Make sure it's JSON - // Send the request - 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 { - 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 -} - -func sendRequest(req *http.Request) (err error) { - resp, err := http.DefaultClient.Do(req) // Perform the http request - if err != nil { - return err - } - defer resp.Body.Close() - _, err = io.ReadAll(resp.Body) // Read the response body, and check for errors/bad statuscodes - if err != nil { - return - } - if resp.StatusCode > 299 { - return errStatusCode - } - 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 deleted file mode 100644 index ee31ba7..0000000 --- a/ZigBeeValve/thing_test.go +++ /dev/null @@ -1,445 +0,0 @@ -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 deleted file mode 100644 index c9e4f72..0000000 --- a/ZigBeeValve/zigbee_test.go +++ /dev/null @@ -1,117 +0,0 @@ -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/collector/README.md b/collector/README.md new file mode 100644 index 0000000..303f720 --- /dev/null +++ b/collector/README.md @@ -0,0 +1,91 @@ + +# collector + +This is a data collector for gathering statistics from other Arrowwhead systems. +The collected samples will then be sent to an InfluxDB instance, which can present +the data as pretty graphs for example. + +## Setup + +This requires a locally running InfluxDB instance. + +The easiest way to run everything is by using the provided `docker-compose.yml` +file in the root folder of this repository. Copy the file and update the settings +to your liking. For example, it would be a good idea to change the name and password +of the InfluxDB administrator. + +Next, you have to run the collector system once to generate a new default config +file and letting InfluxDB run it's setup. +This can be done by running `docker-compose up`. + +*Note that the collector will print an error and quit, which may look confusing but +it was simply generating a new config.* + +Clean up the containers by running `docker-compose down`. + +Edit the config file located at `./data/collector/systemconfig.json`. +The Influx settings should reflect the settings used in `docker-compose.yml`. +The authorisation token for Influx's API can be found in `./data/influxdb/config/influx-configs`. +A default set of sampled services has been provided by default, make any required +changes that reflects your own setup. + +## Running + +When the setup have been performed, you can run the docker containers again. +You can browse and login to InfluxDB by visiting `http://localhost:8086/`. + +- Running systems in the background: `docker-compose up -d` +- Stopping all systems: `docker-compose down` +- Show the system logs: `docker-compose logs` +- Show live logs: `docker-compose --tail 100 -f` + +## Design + +The following sequence diagram documents the work flows this system performs +while running and how it interacts with the other Arrowhead systems and InfluxDB. + +```mermaid +sequenceDiagram +participant sr as ServiceRegistrar +participant or as Orchestrator +participant col as collector +participant sys as Any Arrowhead System +participant inf as InfluxDB + +loop Before registration expiration + activate col + col->>+sr: Register system + sr-->>-col: New expiration time + deactivate col +end + +loop Every x period + alt Service location is unknown + activate col + col->>+or: Discover service provider + activate or + or->>+sr: Query for service + sr-->>-or: Return service location + or-->>col: Forward service location + deactivate or + deactivate col + end + + activate col + + loop For each wanted service + col->>sys: Get statistics from service + activate sys + sys-->>col: Return latest data + deactivate sys + col->>col: Cache sampled data + end + + col->>inf: Batch send the cached data + activate inf + inf-->>col: ok + deactivate inf + + deactivate col +end +``` diff --git a/collector/collect_test.go b/collector/collect_test.go index 2cb08f5..06626d5 100644 --- a/collector/collect_test.go +++ b/collector/collect_test.go @@ -14,50 +14,27 @@ import ( ) type mockTransport struct { + oldTrans http.RoundTripper respCode int respBody io.ReadCloser - - // hits map[string]int - // returnError bool - // resp *http.Response - // err error } -func newMockTransport() mockTransport { - t := mockTransport{ +func newMockTransport() (trans mockTransport, restore func()) { + trans = mockTransport{ + oldTrans: http.DefaultClient.Transport, respCode: 200, respBody: io.NopCloser(strings.NewReader("")), - - // hits: make(map[string]int), - // err: err, - // returnError: retErr, - // resp: resp, + } + restore = func() { + // Use this func to restore the default value + http.DefaultClient.Transport = trans.oldTrans } // Hijack the default http client so no actual http requests are sent over the network - http.DefaultClient.Transport = t - return t + http.DefaultClient.Transport = trans + return } 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, @@ -65,15 +42,27 @@ func (t mockTransport) RoundTrip(req *http.Request) (resp *http.Response, err er }, 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" }`, + "consumption": `{ "value": 32, "unit": "Wh", "timestamp": "%s", "version": "SignalA_v1.0" }`, + "state": `{ "value": 1, "unit": "Binary", "timestamp": "%s", "version": "SignalA_v1.0" }`, + "power": `{ "value": 330, "unit": "Wh", "timestamp": "%s", "version": "SignalA_v1.0" }`, + "current": `{ "value": 9, "unit": "mA", "timestamp": "%s", "version": "SignalA_v1.0" }`, + "voltage": `{ "value": 229, "unit": "V", "timestamp": "%s", "version": "SignalA_v1.0" }`, } +const ( + mockBodyType string = "application/json" + + mockStateIncomplete string = `{ "value": 20, "timestamp": "%s" }` + mockStateBadVersion string = `{ "value": false, "timestamp": "%s", "version": "SignalB_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") @@ -93,18 +82,64 @@ func mockGetState(c *components.Cervice, s *components.System) (f forms.Form, er } func TestCollectService(t *testing.T) { - newMockTransport() - ua := newUnitAsset(*initTemplate(), newSystem(), nil) + _, restore := newMockTransport() + ua := newUnitAsset(*initTemplate(), newSystem()) + defer func() { + // Make sure to run cleanups! Otherwise you'll get leftover errors from influx + ua.cleanup() + restore() + }() ua.apiGetState = mockGetState + sample := Sample{"setpoint", map[string][]string{"Location": {"Kitchen"}}} + + // Good case + err := ua.collectService(sample) + if err != nil { + t.Fatalf("Expected nil error, got: %s", err) + } + good := mockStates["setpoint"] - // for _, service := range consumeServices { - // err := ua.collectService(service) - // if err != nil { - // t.Fatalf("Expected nil error while pulling %s, got: %s", service, err) - // } - // } + // Bad case: a service returns incomplete data + mockStates["setpoint"] = mockStateIncomplete + err = ua.collectService(sample) + if err == nil { + t.Fatalf("Expected error, got nil") + } + + // Bad case: a service returns bad form version + mockStates["setpoint"] = mockStateBadVersion + err = ua.collectService(sample) + if err == nil { + t.Fatalf("Expected error, got nil") + } + + // WARN: Don't forget to restore the mocks! + mockStates["setpoint"] = good +} + +func TestCollectAllServices(t *testing.T) { + _, restore := newMockTransport() + ua := newUnitAsset(*initTemplate(), newSystem()) + defer func() { + ua.cleanup() + restore() + }() + ua.apiGetState = mockGetState + + // Good case err := ua.collectAllServices() if err != nil { t.Fatalf("Expected nil error, got: %s", err) } + good := mockStates["setpoint"] + + // Bad case: a service returns incomplete data + mockStates["setpoint"] = mockStateIncomplete + err = ua.collectAllServices() + if err == nil { + t.Fatalf("Expected error, got nil") + } + + // WARN: Don't forget to restore the mocks! + mockStates["setpoint"] = good } diff --git a/collector/startup_test.go b/collector/startup_test.go new file mode 100644 index 0000000..b047f07 --- /dev/null +++ b/collector/startup_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "fmt" + "testing" + "time" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/influxdb-client-go/v2/api" + influxdb2http "github.com/influxdata/influxdb-client-go/v2/api/http" + "github.com/influxdata/influxdb-client-go/v2/domain" +) + +var errNotImplemented = fmt.Errorf("method not implemented") + +type mockInflux struct { + closeCh chan bool +} + +// NOTE: This influxdb2.Client interface is too fatty, must add lot's of methods.. +func (i *mockInflux) Setup(ctx context.Context, username, password, org, bucket string, retentionPeriodHours int) (*domain.OnboardingResponse, error) { + return nil, errNotImplemented +} +func (i *mockInflux) SetupWithToken(ctx context.Context, username, password, org, bucket string, retentionPeriodHours int, token string) (*domain.OnboardingResponse, error) { + return nil, errNotImplemented +} +func (i *mockInflux) Ready(ctx context.Context) (*domain.Ready, error) { + return nil, errNotImplemented +} +func (i *mockInflux) Health(ctx context.Context) (*domain.HealthCheck, error) { + return nil, errNotImplemented +} +func (i *mockInflux) Ping(ctx context.Context) (bool, error) { + return true, nil +} +func (i *mockInflux) Close() { + close(i.closeCh) +} +func (i *mockInflux) Options() *influxdb2.Options { + return nil +} +func (i *mockInflux) ServerURL() string { + return errNotImplemented.Error() +} +func (i *mockInflux) HTTPService() influxdb2http.Service { + return nil +} +func (i *mockInflux) WriteAPI(org, bucket string) api.WriteAPI { + return nil +} +func (i *mockInflux) WriteAPIBlocking(org, bucket string) api.WriteAPIBlocking { + return nil +} +func (i *mockInflux) QueryAPI(org string) api.QueryAPI { + return nil +} +func (i *mockInflux) AuthorizationsAPI() api.AuthorizationsAPI { + return nil +} +func (i *mockInflux) OrganizationsAPI() api.OrganizationsAPI { + return nil +} +func (i *mockInflux) UsersAPI() api.UsersAPI { + return nil +} +func (i *mockInflux) DeleteAPI() api.DeleteAPI { + return nil +} +func (i *mockInflux) BucketsAPI() api.BucketsAPI { + return nil +} +func (i *mockInflux) LabelsAPI() api.LabelsAPI { + return nil +} +func (i *mockInflux) TasksAPI() api.TasksAPI { + return nil +} +func (i *mockInflux) APIClient() *domain.Client { + return nil +} + +func TestStartup(t *testing.T) { + sys := newSystem() // Needs access to the context cancel'r func + ua := newUnitAsset(*initTemplate(), sys) + + // Bad case: too short collection period + goodPeriod := ua.CollectionPeriod + ua.CollectionPeriod = 0 + err := ua.startup() + if err == nil { + t.Fatalf("Expected error, got nil") + } + ua.CollectionPeriod = goodPeriod + + // Good case: startup() enters loop and can be shut down again + c := make(chan bool) + ua.influx = &mockInflux{closeCh: c} + go ua.startup() + sys.cancel() + // Wait for startup() to quit it's loop and call cleanup(), which in turn + // should call influx.Close(). If it times out it failed. + select { + case <-c: + case <-time.After(200 * time.Millisecond): + t.Fatalf("Expected startup to quit and call close(), but timed out") + } +} diff --git a/collector/system.go b/collector/system.go index 5cc605f..095804a 100644 --- a/collector/system.go +++ b/collector/system.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "log" "sync" @@ -12,7 +13,9 @@ import ( func main() { sys := newSystem() - sys.loadConfiguration() + if err := sys.loadConfiguration(); err != nil { + log.Fatalf("Error loading config: %s\n", err) + } // Generate PKI keys and CSR to obtain a authentication certificate from the CA usecases.RequestCertificate(&sys.System) @@ -23,12 +26,15 @@ func main() { usecases.RegisterServices(&sys.System) // Run forever - sys.listenAndServe() + if err := sys.listenAndServe(); err != nil { + log.Fatalf("Error running system: %s\n", err) + } } //////////////////////////////////////////////////////////////////////////////// -// There's no interface to use, so have to encapsulate the base struct instead +// There's no interface to use, so have to encapsulate the base struct instead. +// This allows for access/storage of internal vars shared system-wide. type system struct { components.System @@ -36,6 +42,9 @@ type system struct { startups []func() error } +const systemName string = "Collector" + +// Creates a new system with a context and husk prepared for later use. 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. @@ -46,29 +55,34 @@ func newSystem() (sys *system) { // operations that's required of an Arrowhead system. // var sys system sys = &system{ - System: components.NewSystem("Collector", ctx), + System: components.NewSystem(systemName, 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}, + ProtoPort: map[string]int{"https": 8666, "http": 8666, "coap": 0}, InfoLink: "https://github.com/lmas/d0020e_code/tree/master/collector", } return } -func (sys *system) loadConfiguration() { +// Allows for mocking this extremely heavy function call +var configureSystem = usecases.Configure + +// Try load configuration from the standard "systemconfig.json" file. +// Any unit assets will be prepared for later startup. +// WARN: An error is raised if the config file is missing! +func (sys *system) loadConfiguration() (err error) { // 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) + rawUAs, _, err := configureSystem(&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) + return } // Load the proper unit asset(s) using the user-defined settings from the config file. @@ -76,25 +90,26 @@ func (sys *system) loadConfiguration() { for _, raw := range rawUAs { var uac unitAsset if err := json.Unmarshal(raw, &uac); err != nil { - log.Fatalf("Error while unmarshalling configuration: %+v\n", err) + return fmt.Errorf("unmarshalling json config: %w", err) } - // ua, startup := newUnitAsset(uac, &sys.System, servsTemp) - // ua := newUnitAsset(uac, &sys.System, servsTemp) - ua := newUnitAsset(uac, sys, servsTemp) + ua := newUnitAsset(uac, sys) sys.startups = append(sys.startups, ua.startup) intf := components.UnitAsset(ua) sys.UAssets[ua.GetName()] = &intf } + return } -func (sys *system) listenAndServe() { +// Run the system and all the unit assets, blocking until user cancels or an +// error is raised in any background workers. +func (sys *system) listenAndServe() (err error) { 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) + if e := usecases.SetoutServers(&sys.System); e != nil { + err = fmt.Errorf("web server: %w", e) sys.cancel() } wg.Done() @@ -104,8 +119,8 @@ func (sys *system) listenAndServe() { 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) + if e := start(); e != nil { + err = fmt.Errorf("startup: %w", e) sys.cancel() } wg.Done() @@ -115,11 +130,12 @@ func (sys *system) listenAndServe() { // Block and wait for either a... select { case <-sys.Sigs: // user initiated shutdown signal (ctrl+c) or a... + log.Println("Initiated shutdown, waiting for workers to terminate") 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() + return } diff --git a/collector/system_test.go b/collector/system_test.go new file mode 100644 index 0000000..83dc105 --- /dev/null +++ b/collector/system_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + "github.com/sdoque/mbaigo/components" +) + +type mockConfigure struct { + createFile bool + badUnit bool +} + +func (c *mockConfigure) load(sys *components.System) (raws []json.RawMessage, servs []components.Service, err error) { + if c.createFile { + err = fmt.Errorf("a new configuration file has been written") + return + } + if c.badUnit { + raws = []json.RawMessage{json.RawMessage("}")} + return + } + b, err := json.Marshal(initTemplate()) + if err != nil { + return + } + raws = []json.RawMessage{json.RawMessage(b)} + return +} + +func TestLoadConfig(t *testing.T) { + sys := newSystem() + + // Good case: loads config + conf := &mockConfigure{} + configureSystem = conf.load + if err := sys.loadConfiguration(); err != nil { + t.Fatalf("Expected nil error, got: %s", err) + } + _, found := sys.UAssets[uaName] + if !found { + t.Fatalf("Expected to find loaded unitasset, got nil") + } + + // Bad case: stop system startup if config file is missing + conf = &mockConfigure{createFile: true} + configureSystem = conf.load + if err := sys.loadConfiguration(); err == nil { + t.Fatalf("Expected error, got nil") + } + + // Bad case: fails to unmarshal json for unit + conf = &mockConfigure{badUnit: true} + configureSystem = conf.load + if err := sys.loadConfiguration(); err == nil { + t.Fatalf("Expected error, got nil") + } +} + +//////////////////////////////////////////////////////////////////////////////// + +var errShutdown = fmt.Errorf("test startup error") + +func TestListenAndServe(t *testing.T) { + // Bad case: startup returns error + sys := newSystem() + sys.startups = []func() error{ + func() error { + return errShutdown + }, + } + + c := make(chan bool) + go func(logf func(string, ...any)) { + if err := sys.listenAndServe(); !errors.Is(err, errShutdown) { + logf("Expected startup error, got: %s", err) + } + close(c) + }(t.Errorf) + + // Wait for graceful shutdown, fail if it times out. + // The timeout might cause flaky testing here (if the shutdown takes longer + // than usual). I'm averaging about 1s on a laptop. + select { + case <-c: + case <-time.After(2000 * time.Millisecond): + t.Fatalf("Expected startup to quit and call close(), but timed out") + } + + // NOTE: Don't bother trying to test for errors from usecases.SetoutServers() +} diff --git a/collector/unitasset.go b/collector/unitasset.go index 34f68cf..e69e383 100644 --- a/collector/unitasset.go +++ b/collector/unitasset.go @@ -7,11 +7,13 @@ import ( "fmt" "log" "net/http" + "strings" "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" @@ -32,20 +34,30 @@ type unitAsset struct { 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 + 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 + Samples []Sample `json:"samples"` // Arrowhead services we want to sample data from // Mockable function for getting states from the consumed services. apiGetState func(*components.Cervice, *components.System) (forms.Form, error) - // + // internal things for talking with Influx influx influxdb2.Client influxWriter api.WriteAPI } +// A Sample is a struct that defines a service to be sampled. +// The service sampled is identified using the details map. +// Inspired from: +// https://github.com/vanDeventer/metalepsis/blob/9752ee11657a44fd701e3c3b4f75c592d001a5e5/Influxer/thing.go#L38 +type Sample struct { + Service string `json:"service"` + Details map[string][]string `json:"details"` +} + // 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) @@ -77,45 +89,41 @@ 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"}}, - + Name: uaName, InfluxDBHost: "http://localhost:8086", InfluxDBToken: "insert secret token here", InfluxDBOrganisation: "organisation", InfluxDBBucket: "arrowhead", CollectionPeriod: 30, + Samples: []Sample{ + {"temperature", map[string][]string{"Location": {"Kitchen"}}}, + {"SEKPrice", map[string][]string{"Location": {"Kitchen"}}}, + {"DesiredTemp", map[string][]string{"Location": {"Kitchen"}}}, + {"setpoint", map[string][]string{"Location": {"Kitchen"}}}, + {"consumption", map[string][]string{"Location": {"Kitchen"}}}, + {"state", map[string][]string{"Location": {"Kitchen"}}}, + {"power", map[string][]string{"Location": {"Kitchen"}}}, + {"current", map[string][]string{"Location": {"Kitchen"}}}, + {"voltage", map[string][]string{"Location": {"Kitchen"}}}, + }, } } -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 { +// newUnitAsset creates a new instance of UnitAsset, using settings and values +// loaded from an existing configuration file. +// Returns an UA instance that is ready to be published and used by others. +func newUnitAsset(uac unitAsset, sys *system) *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? + Name: uac.Name, + Owner: &sys.System, + Details: uac.Details, CervicesMap: components.Cervices{}, InfluxDBHost: uac.InfluxDBHost, @@ -123,33 +131,27 @@ func newUnitAsset(uac unitAsset, sys *system, servs []components.Service) *unitA InfluxDBOrganisation: uac.InfluxDBOrganisation, InfluxDBBucket: uac.InfluxDBBucket, CollectionPeriod: uac.CollectionPeriod, + Samples: uac.Samples, - apiGetState: usecases.GetState, - influx: client, + // Default to using the API method, outside of tests. + apiGetState: usecases.GetState, + influx: client, + // "[The async] WriteAPI automatically logs write errors." Source: + // https://pkg.go.dev/github.com/influxdata/influxdb-client-go/v2#readme-reading-async-errors 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), + // Maps the services we want to sample. The services will then be looked up + // using the Orchestrator. + // Again based on code from VanDeventer. + for _, s := range ua.Samples { + ua.CervicesMap[s.Service] = &components.Cervice{ + Name: s.Service, + Details: s.Details, + 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 } @@ -162,8 +164,6 @@ func (ua *unitAsset) startup() (err error) { return errTooShortPeriod } - // TODO: try connecting to influx, check if need to call Health()/Ping()/Ready()/Setup()? - for { select { // Wait for a shutdown signal @@ -171,10 +171,10 @@ func (ua *unitAsset) startup() (err error) { ua.cleanup() return - // Wait until it's time to collect new data + // 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 + log.Println("Error: ", err) } } } @@ -185,45 +185,42 @@ func (ua *unitAsset) cleanup() { } func (ua *unitAsset) collectAllServices() (err error) { - // log.Println("tick") // TODO var wg sync.WaitGroup - - for _, service := range consumeServices { + for _, sample := range ua.Samples { wg.Add(1) - go func(s string) { - if err := ua.collectService(s); err != nil { - log.Printf("Error collecting data from %s: %s", s, err) + go func(s Sample) { + if e := ua.collectService(s); e != nil { + err = fmt.Errorf("collecting data from %s: %w", s, e) } wg.Done() - }(service) + }(sample) } + // Errors from the writer are caught in another goroutine and logged there wg.Wait() ua.influxWriter.Flush() - return nil + return } -func (ua *unitAsset) collectService(service string) (err error) { - f, err := ua.apiGetState(ua.CervicesMap[service], ua.Owner) +func (ua *unitAsset) collectService(sam Sample) (err error) { + f, err := ua.apiGetState(ua.CervicesMap[sam.Service], ua.Owner) if err != nil { - return // TODO: use a better error? + return fmt.Errorf("failed to get state: %w", err) } - // fmt.Println(f) - s, ok := f.(*forms.SignalA_v1a) + sig, 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) + ua.influxWriter.WritePoint(influxdb2.NewPoint( + sam.Service, + map[string]string{ + "unit": sig.Unit, + "location": strings.Join(sam.Details["Location"], "-"), + }, + map[string]interface{}{"value": sig.Value}, + sig.Timestamp.UTC(), + )) return nil } diff --git a/docker-compose.yml b/docker-compose.yml index e385843..fa203f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,7 @@ services: build: context: ./src args: - - SRC=./ZigBeeValve/*.go + - SRC=./ZigBeeHandler/*.go - PORT=8870 depends_on: - registrar diff --git a/go.mod b/go.mod index f17d075..72c88c2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/lmas/d0020e_code go 1.23 require ( - github.com/coder/websocket v1.8.12 + github.com/gorilla/websocket v1.5.3 github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/sdoque/mbaigo v0.0.0-20241019053937-4e5abf6a2df4 ) diff --git a/go.sum b/go.sum index e584cd1..4cc0449 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,13 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMz github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=