diff --git a/.vscode/launch.json b/.vscode/launch.json index b994667..fb0855d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceFolder}/main.go", + "program": "${workspaceFolder}/src/main.go", } ] } \ No newline at end of file diff --git a/Makefile b/Makefile index 95fb7c2..121b2f7 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ -PROJECT_NAME := homeservice +PROJECT_NAME := waterservice PREFIX ?= /usr/bin CONFIG_DIR := /etc/$(PROJECT_NAME) +CONFIG_FILE := config.json +DEVICES_DIR := $(CONFIG_DIR) +DEVICES_FILE := devices.json SYSTEM_DIR := /usr/lib/systemd/system -WEB_DIR := /var/lib/$(PROJECT_NAME) +WEB_DIR := /var/lib/$(PROJECT_NAME)/webui BIN_FILE := build/bin/$(PROJECT_NAME) UNIT_FILE := $(PROJECT_NAME).service @@ -16,15 +19,12 @@ all: service .PHONY: service service: mkdir -p bin - go clean - go mod tidy - go build -o $(BIN_FILE) + cd src && go build -o ../$(BIN_FILE) .PHONY: clean clean: - go clean - rm -rf bin - -rm $(PROJECT_NAME).tar.gz + cd src && go clean + rm -rf build .PHONY: install install: all @@ -40,13 +40,29 @@ install: all install -d $(WEB_DIR) cp -r webui/* $(WEB_DIR) + # Config file + @if [ -f $(CONFIG_DIR)/$(notdir $(CONFIG_FILE)) ]; then \ + echo "$(CONFIG_DIR)/$(notdir $(CONFIG_FILE)) already exists - skipping..."; \ + else \ + install -d $(CONFIG_DIR); \ + install -m 0644 config/$(CONFIG_FILE) $(CONFIG_DIR); \ + echo "install -d $(CONFIG_DIR)"; \ + echo "install -m 0644 $(CONFIG_FILE) $(CONFIG_DIR)"; \ + fi + + # devices file + @if [ -f $(DEVICES_DIR)/$(notdir $(DEVICES_FILE)) ]; then \ + echo "$(DEVICES_DIR)/$(notdir $(DEVICES_FILE)) already exists - skipping..."; \ + else \ + install -d $(DEVICES_DIR); \ + install -m 0644 config/$(DEVICES_FILE) $(DEVICES_DIR); \ + echo "install -d $(DEVICES_DIR)"; \ + echo "install -m 0644 $(DEVICES_FILE) $(DEVICES_DIR)"; \ + fi + .PHONY: uninstall uninstall: rm -rf $(CONFIG_DIR) rm -rf $(SYSTEM_DIR)/$(UNIT_FILE) rm -rf $(PREFIX)/$(PROJECT_NAME) rm -rf $(WEB_DIR) - -.PHONY: package -package: all - tar cvzf $(PROJECT_NAME).tar.gz $(CONFIG_FILE) $(BIN_FILE) $(UNIT_FILE) $(README_FILE) webui/build/* diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..6b63557 --- /dev/null +++ b/config/config.json @@ -0,0 +1,9 @@ +{ + "influx": { + "host": "p5.local", + "port": 8086, + "token": "", + "org": "tkl", + "bucket": "home" + } +} diff --git a/config/devices.json b/config/devices.json new file mode 100644 index 0000000..4567580 --- /dev/null +++ b/config/devices.json @@ -0,0 +1,84 @@ +{ + "devices": [ + { + "name": "Hühnerauslauf", + "url": "http://192.168.178.56:8080/katara/chicken", + "cmd_on": "{\"value\":\"on\"}", + "cmd_off": "{\"value\":\"off\"}", + "runtime": { + "value": 60, + "unit": "min" + } + }, + { + "name": "Am Hochbeet", + "url": "http://192.168.178.56:8080/katara/bed", + "cmd_on": "{\"value\":\"on\"}", + "cmd_off": "{\"value\":\"off\"}", + "runtime": { + "value": 45, + "unit": "min" + } + }, + { + "name": "An der Sauna", + "url": "http://192.168.178.56:8080/katara/sauna", + "cmd_on": "{\"value\":\"on\"}", + "cmd_off": "{\"value\":\"off\"}", + "runtime": { + "value": 15, + "unit": "min" + } + }, + { + "name": "An der Haselnuss", + "url": "https://perinode-ms26f.local/sample/gpio2", + "cmd_on": "{\"data\": true}", + "cmd_off": "{\"data\": false}", + "runtime": { + "value": 60, + "unit": "min" + } + }, + { + "name": "Am Holzlager", + "url": "https://perinode-gzzx6.local/sample/gpio1", + "cmd_on": "{\"data\": true}", + "cmd_off": "{\"data\": false}", + "runtime": { + "value": 15, + "unit": "min" + } + }, + { + "name": "Tomatentuppen", + "url": "https://perinode-jim7u.local/sample/gpio1", + "cmd_on": "{\"data\": true}", + "cmd_off": "{\"data\": false}", + "runtime": { + "value": 5, + "unit": "min" + } + }, + { + "name": "Zwischen Scheune und Haus", + "url": "https://perinode-jim7u.local/sample/gpio2", + "cmd_on": "{\"data\": true}", + "cmd_off": "{\"data\": false}", + "runtime": { + "value": 60, + "unit": "min" + } + }, + { + "name": "Frei", + "url": "https://perinode-ms26f.local/sample/gpio1", + "cmd_on": "{\"data\": true}", + "cmd_off": "{\"data\": false}", + "runtime": { + "value": 60, + "unit": "min" + } + } + ] +} diff --git a/go.mod b/go.mod deleted file mode 100644 index f1e3a25..0000000 --- a/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -module webserver - -go 1.20 - -require ( - git.blackfinn.de/apiservice/bicycle v0.0.2 - git.blackfinn.de/apiservice/sauna v1.1.0 -) - -require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.1 // indirect - github.com/influxdata/influxdb-client-go/v2 v2.13.0 // indirect - github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect - github.com/oapi-codegen/runtime v1.0.0 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sync v0.6.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index d4df380..0000000 --- a/go.sum +++ /dev/null @@ -1,34 +0,0 @@ -git.blackfinn.de/apiservice/bicycle v0.0.2 h1:q+DMnWzxa4ZWxwMf03OPhQyDGRii7l7YmrEKYLgwn2A= -git.blackfinn.de/apiservice/bicycle v0.0.2/go.mod h1:ygL9Ax8JreS+taIsqNq0NNlmhGnrHE+v1NE8Solihls= -git.blackfinn.de/apiservice/sauna v1.1.0 h1:F2qysKjOld5lwTT7VJCY1YIEz6rXtE8CLpEfkGmeM3o= -git.blackfinn.de/apiservice/sauna v1.1.0/go.mod h1:yM5lSlCApQrrZ7a+Y8ZxzBkLV2Frx6k1cw219XeSEG4= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/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/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= -github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= -github.com/influxdata/influxdb-client-go/v2 v2.13.0 h1:ioBbLmR5NMbAjP4UVA5r9b5xGjpABD7j65pI8kFphDM= -github.com/influxdata/influxdb-client-go/v2 v2.13.0/go.mod h1:k+spCbt9hcvqvUiz0sr5D8LolXHqAAOfPw9v/RIRHl4= -github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf h1:7JTmneyiNEwVBOHSjoMxiWAqB992atOeepeFYegn5RU= -github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= -github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/homeservice.service b/homeservice.service deleted file mode 100644 index 19af6a7..0000000 --- a/homeservice.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=homeservice service -After=multi-user.target - -[Service] -Type=idle -ExecStart=/usr/bin/homeservice -w /var/lib/homeservice/ - -[Install] -WantedBy=multi-user.target diff --git a/main.go b/main.go deleted file mode 100644 index 571faf8..0000000 --- a/main.go +++ /dev/null @@ -1,145 +0,0 @@ -package main - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "flag" - "io" - "log" - "net/http" -) - -type switcher struct { - Name string `json:"name"` - State bool `json:"state"` -} - -var ( - logger log.Logger = *log.Default() -) - -func init() { - logger.SetFlags(log.Llongfile | log.Ltime) -} - -func patch_request(url string, cmd string) error { - req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader([]byte(cmd))) - if err != nil { - return err - } - res, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - logger.Printf("got response!\n") - logger.Printf("status code: %d\n", res.StatusCode) - return nil -} - -func do_the_switch(sw switcher) { - var url string - var cmd string - switch sw.Name { - case "chicken": - url = "http://barsch:8080/katara/chicken" - if sw.State { - cmd = "{\"value\":\"on\"}" - } else { - cmd = "{\"value\":\"off\"}" - } - case "bed": - url = "http://barsch:8080/katara/bed" - if sw.State { - cmd = "{\"value\":\"on\"}" - } else { - cmd = "{\"value\":\"off\"}" - } - case "sauna": - url = "http://barsch:8080/katara/sauna" - if sw.State { - cmd = "{\"value\":\"on\"}" - } else { - cmd = "{\"value\":\"off\"}" - } - case "nut": - url = "https://perinode-ms26f.local/sample/gpio2" - if sw.State { - cmd = "{\"data\": true}" - } else { - cmd = "{\"data\": false}" - } - case "wood": - url = "https://perinode-gzzx6.local/sample/gpio1" - if sw.State { - cmd = "{\"data\": true}" - } else { - cmd = "{\"data\": false}" - } - case "tomato": - url = "https://perinode-jim7u.local/sample/gpio1" - if sw.State { - cmd = "{\"data\": true}" - } else { - cmd = "{\"data\": false}" - } - case "barn": - url = "https://perinode-jim7u.local/sample/gpio1" - if sw.State { - cmd = "{\"data\": true}" - } else { - cmd = "{\"data\": false}" - } - default: - logger.Printf("Unknown switch: %s", sw.Name) - return - } - err := patch_request(url, cmd) - if err != nil { - logger.Print(err) - } -} - -func http_endpoint_water_switch(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-type", "application/json; charset=utf-8;") - if r.Method == http.MethodPatch { - tmp, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write(json.RawMessage(`{"error": "cannot unmarshal json to object"}`)) - } else { - var sw switcher - err = json.Unmarshal(tmp, &sw) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write(json.RawMessage(`{"error": "cannot unmarshal json to object"}`)) - } else { - do_the_switch(sw) - w.WriteHeader(http.StatusAccepted) - } - } - } else { - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -func main() { - logger.Println("starting") - - var webui_path string - flag.StringVar(&webui_path, "w", "./webui", "Specify path to serve the web ui. Default is ./webui") - flag.Parse() - - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - // Serve files from static folder - http.Handle("/", http.FileServer(http.Dir(webui_path))) - - http.HandleFunc("/water/switch", http_endpoint_water_switch) - - port := ":5005" - logger.Println("Server is running on port" + port) - - // Start server on port specified above - logger.Fatal(http.ListenAndServe(port, nil)) - -} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..1ff2546 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,13 @@ +module waterservice + +go 1.24 + +require github.com/influxdata/influxdb-client-go/v2 v2.14.0 + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/oapi-codegen/runtime v1.0.0 // indirect + golang.org/x/net v0.23.0 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..ca5e61e --- /dev/null +++ b/src/go.sum @@ -0,0 +1,27 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= +github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/internal/apiservice/devices/devices.go b/src/internal/apiservice/devices/devices.go new file mode 100644 index 0000000..adc9e2a --- /dev/null +++ b/src/internal/apiservice/devices/devices.go @@ -0,0 +1,75 @@ +package apiservice_devices + +import ( + "encoding/json" + "log" + "net/http" + "os" + "waterservice/internal/app/types" +) + +type Device struct { + Name string `json:"name"` + Url string `json:"url"` + CmdOn string `json:"cmd_on"` + CmdOff string `json:"cmd_off"` + Runtime types.Telemetry `json:"runtime"` +} + +type devices struct { + Devices []Device `json:"devices"` +} + +var ( + logger log.Logger = *log.Default() + devices_path string + devices_cache devices +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) +} + +func read_devices() { + data, err := os.ReadFile(devices_path) + if err != nil { + logger.Printf("Unable to read %s", devices_path) + return + } + err = json.Unmarshal(data, &devices_cache) + if err != nil { + logger.Print("Unable to evaluate config data") + return + } +} + +func SetDevicesFilePath(path string) { + devices_path = path +} + +func AddHandler() { + http.HandleFunc("/devices", handle_endpoint_devices) +} + +func GetDevices() devices { + read_devices() + return devices_cache +} + +func handle_endpoint_devices(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "application/json; charset=utf-8;") + switch r.Method { + case http.MethodGet: + read_devices() + data, err := json.Marshal(devices_cache) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(`{"error":"cannot mashal devices cache"}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write(data) + } + default: + w.WriteHeader(http.StatusNotImplemented) + } +} diff --git a/src/internal/apiservice/mode/mode.go b/src/internal/apiservice/mode/mode.go new file mode 100644 index 0000000..fc60dd0 --- /dev/null +++ b/src/internal/apiservice/mode/mode.go @@ -0,0 +1,61 @@ +package apiservice_mode + +import ( + "encoding/json" + "io" + "net/http" + "waterservice/internal/app/scheduler" +) + +type mode_container struct { + Mode string `json:"mode"` +} + +func AddHandler() { + http.HandleFunc("/mode", handle_endpoint_mode) +} + +func handle_endpoint_mode(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "application/json; charset=utf-8;") + switch r.Method { + case http.MethodGet: + handle_get(w) + case http.MethodPatch: + handle_patch(w, r) + default: + w.WriteHeader(http.StatusNotImplemented) + } +} + +func handle_get(w http.ResponseWriter) { + mode := scheduler.GetMode() + container := mode_container{ + Mode: string(mode), + } + data, err := json.Marshal(container) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(`{"error":"cannot mashal devices cache"}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write(data) + } +} + +func handle_patch(w http.ResponseWriter, r *http.Request) { + tmp, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(`{"error": "cannot read reqest body"}`)) + return + } + var container mode_container + err = json.Unmarshal(tmp, &container) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(`{"error": "cannot unmarshal json to object"}`)) + return + } + scheduler.SetMode(scheduler.AppMode(container.Mode)) + w.WriteHeader(http.StatusAccepted) +} diff --git a/src/internal/apiservice/pv/pv.go b/src/internal/apiservice/pv/pv.go new file mode 100644 index 0000000..f278e19 --- /dev/null +++ b/src/internal/apiservice/pv/pv.go @@ -0,0 +1,83 @@ +package apiservice_pv + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "waterservice/internal/app/config" + "waterservice/internal/app/types" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/influxdb-client-go/v2/api" +) + +var ( + logger log.Logger = *log.Default() + client influxdb2.Client + query_api api.QueryAPI + query string +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) +} + +func Start() { + cfg := config.GetConfig() + // setup influx connection + influxdb_url := "http://" + cfg.Influx.Host + ":" + strconv.Itoa(cfg.Influx.Port) + client = influxdb2.NewClient(influxdb_url, cfg.Influx.Token) + + query = fmt.Sprintf(`from(bucket: "%s") + |> range(start: -10m) + |> filter(fn: (r) => r["_measurement"] == "AlphaEss") + |> filter(fn: (r) => r["_field"] == "BatteryStateOfCharge") + |> last()`, cfg.Influx.Bucket) + + query_api = client.QueryAPI(cfg.Influx.Org) +} + +func AddHandler() { + http.HandleFunc("/battery/stateofcharge", handle_endpoint_stateofcharge) +} + +func handle_endpoint_stateofcharge(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "application/json; charset=utf-8;") + switch r.Method { + case http.MethodGet: + res, err := battery_state_of_charge() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(fmt.Sprintf(`{"error":"%s"}`, err.Error()))) + } + data, err := json.Marshal(res) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(`{"error":"cannot mashal battery state of charge"}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write(data) + } + default: + w.WriteHeader(http.StatusNotImplemented) + } +} + +func battery_state_of_charge() (types.Telemetry, error) { + var ret types.Telemetry + results, err := query_api.Query(context.Background(), query) + if err != nil { + return ret, err + } + for results.Next() { + ret.Value = results.Record().Value().(float64) + } + if err := results.Err(); err != nil { + return ret, err + } + ret.Unit = "%" + return ret, nil +} diff --git a/src/internal/apiservice/soil/moisture.go b/src/internal/apiservice/soil/moisture.go new file mode 100644 index 0000000..b555ce6 --- /dev/null +++ b/src/internal/apiservice/soil/moisture.go @@ -0,0 +1,88 @@ +package apiservice_soil + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "waterservice/internal/app/config" + "waterservice/internal/app/types" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/influxdb-client-go/v2/api" +) + +var ( + logger log.Logger = *log.Default() + client influxdb2.Client + query_api api.QueryAPI + query string +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) +} + +func Start() { + cfg := config.GetConfig() + // setup influx connection + influxdb_url := "http://" + cfg.Influx.Host + ":" + strconv.Itoa(cfg.Influx.Port) + client = influxdb2.NewClient(influxdb_url, cfg.Influx.Token) + + query = fmt.Sprintf(`from(bucket: "%s") + |> range(start: -10m) + |> filter(fn: (r) => r["_measurement"] == "garden") + |> filter(fn: (r) => r["_field"] == "value") + |> filter(fn: (r) => r["soil"] == "moisture") + |> last()`, cfg.Influx.Bucket) + + query_api = client.QueryAPI(cfg.Influx.Org) +} + +func AddHandler() { + http.HandleFunc("/soil/moisture", handle_endpoint_moisture) +} + +func handle_endpoint_moisture(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "application/json; charset=utf-8;") + switch r.Method { + case http.MethodGet: + res, err := moisture() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(fmt.Sprintf(`{"error":"%s"}`, err.Error()))) + } + data, err := json.Marshal(res) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write(json.RawMessage(`{"error":"cannot mashal moisture"}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write(data) + } + default: + w.WriteHeader(http.StatusNotImplemented) + } +} + +func moisture() (types.Telemetry, error) { + var ret types.Telemetry + results, err := query_api.Query(context.Background(), query) + if err != nil { + return ret, err + } + for results.Next() { + ret.Value = results.Record().Value().(float64) + } + if err := results.Err(); err != nil { + return ret, err + } + // re-calculate to % + // 100 % := 3.0 V + // 0 % := 0.0 V + ret.Value = -0.03*ret.Value + 3 + ret.Unit = "%" + return ret, nil +} diff --git a/src/internal/apiservice/state/requests.go b/src/internal/apiservice/state/requests.go new file mode 100644 index 0000000..b196750 --- /dev/null +++ b/src/internal/apiservice/state/requests.go @@ -0,0 +1,54 @@ +package apiservice_state + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +func patch(url string, cmd string) error { + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader([]byte(cmd))) + if err != nil { + return err + } + _, err = http.DefaultClient.Do(req) + if err != nil { + return err + } + return nil +} + +func get(url string) (state, error) { + resp, err := http.Get(url) + if err != nil { + return StateOff, err + } + if resp.StatusCode != http.StatusOK { + return StateOff, fmt.Errorf("error: received status code: %d", resp.StatusCode) + } + res, err := io.ReadAll(resp.Body) + if err != nil { + return StateOff, err + } + var dev map[string]any + err = json.Unmarshal(res, &dev) + if err != nil { + return StateOff, err + } + tmp := dev["Value"] + if tmp != nil { + return state(tmp.(string)), nil + } + tmp = dev["data"] + if tmp != nil { + if tmp.(bool) { + return StateOn, nil + } else { + return StateOff, nil + } + } + return StateOff, errors.New("unknown result") +} diff --git a/src/internal/apiservice/state/state.go b/src/internal/apiservice/state/state.go new file mode 100644 index 0000000..9a44c4c --- /dev/null +++ b/src/internal/apiservice/state/state.go @@ -0,0 +1,220 @@ +package apiservice_state + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" + apiservice_devices "waterservice/internal/apiservice/devices" +) + +type state string + +type registerOffSwitch func(apiservice_devices.Device) + +type State struct { + Name string `json:"name"` + State state `json:"state"` + Runtime time.Duration `json:"runtime"` +} + +const ( + StateOn state = "on" + StateOff state = "off" +) + +var ( + logger log.Logger = *log.Default() + + state_cache map[string]State + state_mutex sync.Mutex + cb_register_off registerOffSwitch + cb_unregister_off registerOffSwitch +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) + state_cache = map[string]State{} +} + +func AddHandler() { + http.HandleFunc("/state", handle_endpoint_switch) +} + +func Start() { + state_mutex.Lock() + for _, device := range apiservice_devices.GetDevices().Devices { + tmp := State{ + Name: device.Name, + State: StateOff, + Runtime: 0, + } + state_cache[device.Name] = tmp + } + state_mutex.Unlock() + + go poll_states() +} + +func SetOffSwitchCallbacks(cb_register registerOffSwitch, cb_unregister registerOffSwitch) { + cb_register_off = cb_register + cb_unregister_off = cb_unregister +} + +func SetState(st State) error { + devices := apiservice_devices.GetDevices() + for _, dev := range devices.Devices { + if dev.Name == st.Name { + url := dev.Url + var cmd string + switch st.State { + case StateOn: + cmd = dev.CmdOn + case StateOff: + cmd = dev.CmdOff + } + err := patch(url, cmd) + if err != nil { + return err + } + state_mutex.Lock() + tmp := state_cache[dev.Name] + tmp.State = st.State + state_cache[dev.Name] = tmp + state_mutex.Unlock() + if cb_register_off != nil && cb_unregister_off != nil { + if st.State == StateOn { + cb_register_off(dev) + } else { + cb_unregister_off(dev) + } + } + return nil + } + } + // FIXME: device not found... + return nil +} + +func SetRuntime(name string, runtime time.Duration) { + state_mutex.Lock() + tmp := state_cache[name] + tmp.Runtime = runtime + state_cache[name] = tmp + state_mutex.Unlock() +} + +func handle_endpoint_switch(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "application/json; charset=utf-8;") + switch r.Method { + case http.MethodPatch: + err := handle_patch(r) + if err != nil { + logger.Print(err) + w.WriteHeader(http.StatusInternalServerError) + err_str := fmt.Sprintf("{\"error\": \"%s\"}", err.Error()) + w.Write(json.RawMessage([]byte(err_str))) + return + } + w.WriteHeader(http.StatusAccepted) + case http.MethodGet: + data, err := handle_get() + if err != nil { + logger.Print(err) + w.WriteHeader(http.StatusInternalServerError) + err_str := fmt.Sprintf("{\"error\": \"%s\"}", err.Error()) + w.Write(json.RawMessage([]byte(err_str))) + return + } + w.WriteHeader(http.StatusOK) + w.Write(data) + default: + w.WriteHeader(http.StatusNotImplemented) + } +} + +func handle_patch(r *http.Request) error { + tmp, err := io.ReadAll(r.Body) + if err != nil { + return err + } + var st State + err = json.Unmarshal(tmp, &st) + if err != nil { + return err + } + + devices := apiservice_devices.GetDevices() + for _, dev := range devices.Devices { + if dev.Name == st.Name { + url := dev.Url + var cmd string + switch st.State { + case StateOn: + cmd = dev.CmdOn + case StateOff: + cmd = dev.CmdOff + } + err = patch(url, cmd) + if err != nil { + return err + } + state_mutex.Lock() + tmp := state_cache[dev.Name] + tmp.State = st.State + state_cache[dev.Name] = tmp + state_mutex.Unlock() + if cb_register_off != nil && cb_unregister_off != nil { + if st.State == StateOn { + cb_register_off(dev) + } else { + cb_unregister_off(dev) + } + } + return nil + } + } + // FIXME: device not found... + return nil +} + +func handle_get() ([]byte, error) { + var states []State + state_mutex.Lock() + for _, v := range state_cache { + tmp := State{ + Name: v.Name, + State: v.State, + Runtime: v.Runtime, + } + states = append(states, tmp) + } + state_mutex.Unlock() + res, err := json.Marshal(states) + if err != nil { + return res, err + } + return res, nil +} + +func poll_states() { + for { + devices := apiservice_devices.GetDevices() + for _, dev := range devices.Devices { + status, err := get(dev.Url) + if err != nil { + logger.Print(err) + continue + } + state_mutex.Lock() + tmp := state_cache[dev.Name] + tmp.State = status + state_cache[dev.Name] = tmp + state_mutex.Unlock() + } + time.Sleep(time.Second * 10) + } +} diff --git a/src/internal/app/config/config.go b/src/internal/app/config/config.go new file mode 100644 index 0000000..6180d7b --- /dev/null +++ b/src/internal/app/config/config.go @@ -0,0 +1,44 @@ +package config + +import ( + "encoding/json" + "log" + "waterservice/internal/app/storage" +) + +type config struct { + Influx struct { + Host string `json:"host"` + Port int `json:"port"` + Token string `json:"token"` + Org string `json:"org"` + Bucket string `json:"bucket"` + } `json:"influx"` +} + +var ( + logger log.Logger = *log.Default() + store storage.Storage + config_cache config +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) +} + +func SetConfigFilePath(path string) { + store.Path = path + res, err := store.Read() + if err != nil { + logger.Print("unable to read config") + return + } + err = json.Unmarshal(res, &config_cache) + if err != nil { + logger.Print("unable to unmarshal json to object") + } +} + +func GetConfig() config { + return config_cache +} diff --git a/src/internal/app/scheduler/scheduler.go b/src/internal/app/scheduler/scheduler.go new file mode 100644 index 0000000..4490266 --- /dev/null +++ b/src/internal/app/scheduler/scheduler.go @@ -0,0 +1,149 @@ +package scheduler + +import ( + "encoding/json" + "log" + "sync" + "time" + "waterservice/internal/app/storage" + + apiservice_devices "waterservice/internal/apiservice/devices" + apiservice_state "waterservice/internal/apiservice/state" +) + +const ( + app_state_storage_path = "/var/lib/waterservice/scheduler/state.json" + ModeAuto AppMode = "auto" + ModeManual AppMode = "manual" +) + +var ( + logger log.Logger = *log.Default() + app_state_cache app_state + app_state_mutex sync.Mutex + store storage.Storage +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) + + // set defaults + app_state_cache.Mode = ModeManual + + // override defaults with stored values + store.Path = app_state_storage_path + res, err := store.Read() + if err != nil { + logger.Print("unable to read app state cache") + return + } + app_state_mutex.Lock() + err = json.Unmarshal(res, &app_state_cache) + app_state_mutex.Unlock() + if err != nil { + logger.Print("unable to evaluate config data") + } +} + +func SetMode(mode AppMode) { + app_state_mutex.Lock() + app_state_cache.Mode = mode + res, err := json.Marshal(app_state_cache) + if err != nil { + logger.Print("unable to store app state cache") + return + } + store.Write(res) + app_state_mutex.Unlock() +} + +func GetMode() AppMode { + app_state_mutex.Lock() + ret := app_state_cache.Mode + app_state_mutex.Unlock() + return ret +} + +func Start() { + apiservice_state.SetOffSwitchCallbacks(register_off_device, unregister_off_device) + + go app_state_saver() + go poll_auto_off() +} + +func app_state_saver() { + for { + app_state_mutex.Lock() + res, err := json.Marshal(app_state_cache) + app_state_mutex.Unlock() + if err != nil { + logger.Print("unable to marshal object to json") + time.Sleep(time.Minute) + continue + } + store.Write(res) + time.Sleep(time.Minute) + } +} + +func poll_auto_off() { + for { + app_state_mutex.Lock() + for i, od := range app_state_cache.off_devices { + var duration time.Duration + switch od.device.Runtime.Unit { + case "min": + duration = time.Duration(od.device.Runtime.Value) * 60000000000 // minute -> nanosecont + default: + logger.Printf("Unit %s handling not implemented", od.device.Runtime.Unit) + } + off_time := od.start_time.Add(duration) + run_time := time.Since(od.start_time) + apiservice_state.SetRuntime(od.device.Name, run_time) + if time.Now().After(off_time) { + logger.Printf("switching off %s", od.device.Name) + var st = apiservice_state.State{ + Name: od.device.Name, + State: apiservice_state.StateOff, + } + apiservice_state.SetState(st) + if len(app_state_cache.off_devices) > 1 { + app_state_cache.off_devices = append(app_state_cache.off_devices[:i], app_state_cache.off_devices[i+1:]...) + } else { + app_state_cache.off_devices = nil + } + } + } + app_state_mutex.Unlock() + time.Sleep(time.Second) + } +} + +func register_off_device(dev apiservice_devices.Device) { + for _, od := range app_state_cache.off_devices { + if od.device.Name == dev.Name { + // device already in off list + // FIXME: update off time + return + } + } + var tmp off_device + tmp.device = dev + tmp.start_time = time.Now() + app_state_mutex.Lock() + app_state_cache.off_devices = append(app_state_cache.off_devices, tmp) + app_state_mutex.Unlock() +} + +func unregister_off_device(dev apiservice_devices.Device) { + for i, od := range app_state_cache.off_devices { + if od.device.Name == dev.Name { + if len(app_state_cache.off_devices) > 1 { + app_state_cache.off_devices = append(app_state_cache.off_devices[:i], app_state_cache.off_devices[i+1:]...) + } else { + app_state_cache.off_devices = nil + } + return + } + } +} diff --git a/src/internal/app/scheduler/types.go b/src/internal/app/scheduler/types.go new file mode 100644 index 0000000..8013c99 --- /dev/null +++ b/src/internal/app/scheduler/types.go @@ -0,0 +1,18 @@ +package scheduler + +import ( + "time" + apiservice_devices "waterservice/internal/apiservice/devices" +) + +type AppMode string + +type app_state struct { + Mode AppMode + off_devices []off_device +} + +type off_device struct { + device apiservice_devices.Device + start_time time.Time +} diff --git a/src/internal/app/storage/storage.go b/src/internal/app/storage/storage.go new file mode 100644 index 0000000..bb16e61 --- /dev/null +++ b/src/internal/app/storage/storage.go @@ -0,0 +1,50 @@ +package storage + +import ( + "log" + "os" + "path/filepath" +) + +type Storage struct { + Path string +} + +var ( + logger log.Logger = *log.Default() +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) +} + +func (storage Storage) Read() ([]byte, error) { + data, err := os.ReadFile(storage.Path) + if err != nil { + logger.Printf("unable to read %s (%s)", storage.Path, err.Error()) + return nil, err + } + return data, nil +} + +func (storage Storage) Write(data []byte) error { + dir := filepath.Dir(storage.Path) + _, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + err = os.MkdirAll(dir, 0644) + if err != nil { + logger.Printf("unable to create %s (%s)", dir, err.Error()) + return err + } + } else { + logger.Print(err) + return err + } + } + err = os.WriteFile(storage.Path, data, 0644) + if err != nil { + logger.Printf("unable to store %s (%s)", storage.Path, err.Error()) + } + return nil +} diff --git a/src/internal/app/types/types.go b/src/internal/app/types/types.go new file mode 100644 index 0000000..3173cd8 --- /dev/null +++ b/src/internal/app/types/types.go @@ -0,0 +1,6 @@ +package types + +type Telemetry struct { + Value float64 `json:"value"` + Unit string `json:"unit"` +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..145f80c --- /dev/null +++ b/src/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "crypto/tls" + "flag" + "log" + "net/http" + + apiservice_devices "waterservice/internal/apiservice/devices" + apiservice_mode "waterservice/internal/apiservice/mode" + apiservice_pv "waterservice/internal/apiservice/pv" + apiservice_soil "waterservice/internal/apiservice/soil" + apiservice_state "waterservice/internal/apiservice/state" + "waterservice/internal/app/config" + "waterservice/internal/app/scheduler" +) + +var ( + logger log.Logger = *log.Default() +) + +func init() { + logger.SetFlags(log.Llongfile | log.Ltime) +} + +func main() { + logger.Println("starting") + + var webui_path string + var devices_path string + var config_path string + flag.StringVar(&webui_path, "w", "../webui", "Specify path to serve the web ui. Default is ./webui") + flag.StringVar(&devices_path, "d", "../config/devices.json", "Specify path of devices defifintion file. Default is ../config/devices.json") + flag.StringVar(&config_path, "c", "../config/config.json", "Specify path of config file. Default is ../config/config.json") + flag.Parse() + + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + http.Handle("/", http.FileServer(http.Dir(webui_path))) + + config.SetConfigFilePath(config_path) + + apiservice_devices.SetDevicesFilePath(devices_path) + + apiservice_devices.AddHandler() + apiservice_mode.AddHandler() + apiservice_state.AddHandler() + apiservice_pv.AddHandler() + apiservice_soil.AddHandler() + + apiservice_state.Start() + apiservice_pv.Start() + apiservice_soil.Start() + scheduler.Start() + + port := ":5005" + logger.Println("Server is running on port" + port) + + logger.Fatal(http.ListenAndServe(port, nil)) +} diff --git a/waterservice.service b/waterservice.service new file mode 100644 index 0000000..9f01f35 --- /dev/null +++ b/waterservice.service @@ -0,0 +1,10 @@ +[Unit] +Description=waterservice service +After=multi-user.target + +[Service] +Type=idle +ExecStart=/usr/bin/waterservice -w /var/lib/waterservice/webui -d /etc/waterservice/devices.json -c /etc/waterservice/config.json + +[Install] +WantedBy=multi-user.target diff --git a/webui/css/slider.css b/webui/css/slider.css new file mode 100644 index 0000000..a4cc803 --- /dev/null +++ b/webui/css/slider.css @@ -0,0 +1,61 @@ +.switch { + position: relative; + display: inline-block; + width: 60px; + height: 34px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 26px; + width: 26px; + left: 4px; + bottom: 4px; + background-color: white; + -webkit-transition: .4s; + transition: .4s; +} + +input:checked + .slider { + background-color: #5b5b5b; +} + +input:focus + .slider { + box-shadow: 0 0 1px #2196F3; +} + +input:checked + .slider:before { + -webkit-transform: translateX(26px); + -ms-transform: translateX(26px); + transform: translateX(26px); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 34px; +} + +.slider.round:before { + border-radius: 50%; +} diff --git a/webui/css/style.css b/webui/css/style.css new file mode 100644 index 0000000..0123e51 --- /dev/null +++ b/webui/css/style.css @@ -0,0 +1,14 @@ +html, body { + height: 100%; +} + +html { + display: table; + margin: auto; + font-family: Arial, Helvetica, sans-serif; +} + +body { + display: table-cell; + vertical-align: middle; +} diff --git a/webui/index.html b/webui/index.html index 0de4ee0..3db18a8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1,125 +1,142 @@ - - + + Wasser @@ -127,84 +144,33 @@
-
+
- -
- +

Photovoltaik

+
- - - - - - - - - - - - - - - - - - - - - - - - - - + +
Huehner - -
Hochbeet - -
Sauna - -
Haselnuss - -
Holzlager - -
Tomaten - -
Scheune - - Batterielevel:
+

Bodenfeuchtigkeit

+ + + + + +
Bodenfeuchtigkeit:
+

Rasensprenger

+