diff --git a/.env.example b/.env.example index 74da9c7..482d92e 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,19 @@ MQTT_USERNAME= MQTT_PASSWORD= MQTT_CLIENT_ID= HOME_ASSISTANT_DISCOVERY_PREFIX=homeassistant +APP_DEBUG=true + +# 500/240 are defaults in the code, override here +#MOISTURE_SENSOR_1_DRY=500 +#MOISTURE_SENSOR_1_WET=240 +#MOISTURE_SENSOR_2_DRY=500 +#MOISTURE_SENSOR_3_WET=240 +#MOISTURE_SENSOR_3_DRY=500 +#MOISTURE_SENSOR_3_WET=240 +#MOISTURE_SENSOR_4_DRY=500 +#MOISTURE_SENSOR_4_WET=240 +#MOISTURE_SENSOR_5_DRY=500 +#MOISTURE_SENSOR_5_WET=240 +#MOISTURE_SENSOR_6_DRY=500 +#MOISTURE_SENSOR_6_WET=240 + diff --git a/api/api.go b/api/api.go deleted file mode 100644 index 7b344b8..0000000 --- a/api/api.go +++ /dev/null @@ -1,18 +0,0 @@ -package api - -import ( - "log" - "net/http" - - "github.com/mewejo/go-watering/config" -) - -func StartApi(app *config.Application) { - - zoneController := zoneController{ - app: app, - } - - http.HandleFunc("/api/zones/", zoneController.handle) - log.Fatal(http.ListenAndServe(":8080", nil)) -} diff --git a/api/zone_controller.go b/api/zone_controller.go deleted file mode 100644 index 0ad053b..0000000 --- a/api/zone_controller.go +++ /dev/null @@ -1,36 +0,0 @@ -package api - -import ( - "fmt" - "net/http" - "strconv" - - "github.com/mewejo/go-watering/config" - "github.com/mewejo/go-watering/world" -) - -type zoneController struct { - app *config.Application -} - -func (c zoneController) handle(w http.ResponseWriter, r *http.Request) { - percentage, err := strconv.Atoi(r.URL.Query().Get("target")) - - if err != nil { - fmt.Fprintf(w, "Bad percentage!") - return - } - - zoneIndex, err := strconv.Atoi(r.URL.Query().Get("zone")) - - if err != nil { - fmt.Fprintf(w, "Bad zone!") - return - } - - c.app.Zones[zoneIndex].TargetMoisture = world.MoistureLevel{ - Percentage: uint(percentage), - } - - fmt.Fprintf(w, "Set zone %v to %v pc", zoneIndex, percentage) -} diff --git a/arduino/arduino.go b/arduino/arduino.go deleted file mode 100644 index 353987f..0000000 --- a/arduino/arduino.go +++ /dev/null @@ -1,175 +0,0 @@ -package arduino - -import ( - "errors" - "log" - "strings" - "time" - - "go.bug.st/serial" -) - -type Arduino struct { - Port serial.Port -} - -func (a Arduino) SendCommand(command Command) error { - _, err := a.Port.Write([]byte(command)) - - return err -} - -func (a Arduino) ReadData(buffer []byte) (int, error) { - return a.Port.Read(buffer) -} - -func (a Arduino) ReadLine() string { - buff := make([]byte, 1) - data := "" - - for { - n, err := a.ReadData(buff) - - if err != nil { - log.Fatal(err) - } - - if n == 0 { - break - } - - data += string(buff[:n]) - - if strings.Contains(data, "\n") { - break - } - } - - return data -} - -func (a Arduino) ReadLines(until string) []string { - lines := []string{} - - for { - line := a.ReadLine() - - lines = append(lines, line) - - if strings.Contains(line, until) { - break - } - } - - return lines -} - -func (a Arduino) GetReadings() ([]MoistureReading, error) { - err := a.SendCommand(REQUEST_READINGS) - - if err != nil { - return nil, errors.New("could not request readings") - } - - time.Sleep(time.Millisecond * 250) - - readings := []MoistureReading{} - - for _, line := range a.ReadLines("READINGS_END") { - line = strings.TrimSuffix(line, "\n") - line = strings.TrimSuffix(line, "\r") - reading, err := MakeMoistureReadingFromString(line) - - if err != nil { - continue - } - - readings = append( - readings, - reading, - ) - } - - if len(readings) < 1 { - return nil, errors.New("no readings returned from Arduino") - } - - return readings, nil -} - -func (a Arduino) SetAllWaterState(state bool) error { - if state { - return a.SendCommand(WATER_ON) - } else { - return a.SendCommand(WATER_OFF) - } -} - -func (a Arduino) SetWaterState(outlet WaterOutlet, state bool) error { - - var err error - var command Command - - if state { - command, err = outlet.OnCommand() - } else { - command, err = outlet.OffCommand() - } - - if nil != err { - return err - } - - return a.SendCommand(command) -} - -func (a Arduino) WaitUntilReady() { - a.ReadLines("READY") -} - -func findArduinoPort() (string, error) { - ports, err := serial.GetPortsList() - - if err != nil { - log.Fatal(err) - } - - if len(ports) == 0 { - log.Fatal("no serial ports found!") - } - - for _, port := range ports { - if !strings.Contains(port, "ttyACM") { - continue - } - - return port, nil - } - - return "", errors.New("no devices found which look like an Arduino") -} - -func GetArduino() Arduino { - - arduinoPort, err := findArduinoPort() - - if err != nil { - log.Fatal("could not find Arduino port! " + err.Error()) - } - - mode := &serial.Mode{ - BaudRate: 9600, - } - - port, err := serial.Open(arduinoPort, mode) - - if err != nil { - log.Fatal("could not open Arduino port! " + err.Error()) - } - - arduino := Arduino{ - Port: port, - } - - return arduino -} diff --git a/arduino/moisture_reading.go b/arduino/moisture_reading.go deleted file mode 100644 index fa554ee..0000000 --- a/arduino/moisture_reading.go +++ /dev/null @@ -1,59 +0,0 @@ -package arduino - -import ( - "errors" - "strconv" - "strings" - "time" - - "github.com/mewejo/go-watering/world" -) - -type MoistureReading struct { - Time time.Time - Raw int16 - Original world.MoistureLevel - Sensor MoistureSensor -} - -func MakeMoistureReadingFromString(line string) (MoistureReading, error) { - parts := strings.Split(line, ":") - - if parts[0] != "MS" { - return MoistureReading{}, errors.New("line was not a moisture reading") - - } - - sensorId, err := strconv.Atoi(parts[1]) - - if err != nil { - return MoistureReading{}, err - } - - rawValue, err := strconv.Atoi(parts[2]) - - if err != nil { - return MoistureReading{}, err - } - - percentageValue, err := strconv.Atoi(parts[3]) - - if err != nil { - return MoistureReading{}, err - } - - sensor, err := MoistureSensorFromId(sensorId) - - if err != nil { - return MoistureReading{}, err - } - - return MoistureReading{ - Time: time.Now(), - Sensor: sensor, - Raw: int16(rawValue), - Original: world.MoistureLevel{ - Percentage: uint(percentageValue), - }, - }, nil -} diff --git a/arduino/moisture_sensor.go b/arduino/moisture_sensor.go deleted file mode 100644 index 893a356..0000000 --- a/arduino/moisture_sensor.go +++ /dev/null @@ -1,91 +0,0 @@ -package arduino - -import ( - "errors" - "fmt" - "os" - - "github.com/mewejo/go-watering/homeassistant" -) - -type MoistureSensor int - -const ( - MOISTURE_SENSOR_1 MoistureSensor = iota - MOISTURE_SENSOR_2 - MOISTURE_SENSOR_3 - MOISTURE_SENSOR_4 - MOISTURE_SENSOR_5 - MOISTURE_SENSOR_6 -) - -func (ms MoistureSensor) GetHomeAssistantMoistureSensorConfiguration() homeassistant.MoistureSensorConfiguration { - c := homeassistant.NewMoistureSensorConfiguration() - c.Name = fmt.Sprintf("Moisture Sensor #%v", ms.GetId()) - c.ObjectId = ms.GetHomeAssistantObjectId() - c.UniqueId = ms.GetHomeAssistantObjectId() - c.StateTopic = ms.GetHomeAssistantStateTopic() - c.AvailabilityTopic = ms.GetHomeAssistantAvailabilityTopic() - c.Device = homeassistant.NewDeviceDetails() - - return c -} - -func (ms MoistureSensor) GetHomeAssistantStateTopic() string { - return fmt.Sprintf("%v/state", ms.GetHomeAssistantBaseTopic()) -} - -func (ms MoistureSensor) GetHomeAssistantAvailabilityTopic() string { - return fmt.Sprintf("%v/availability", ms.GetHomeAssistantBaseTopic()) -} - -func (ms MoistureSensor) GetHomeAssistantBaseTopic() string { - return fmt.Sprintf( - "%v/sensor/vegetable-soaker/%v", - os.Getenv("HOME_ASSISTANT_DISCOVERY_PREFIX"), - ms.GetHomeAssistantObjectId(), - ) -} - -func (ms MoistureSensor) GetHomeAssistantObjectId() string { - return fmt.Sprintf( - "moisture-sensor-%v", - ms.GetId(), - ) -} - -func (ms MoistureSensor) GetId() int { - if ms == MOISTURE_SENSOR_1 { - return 1 - } else if ms == MOISTURE_SENSOR_2 { - return 2 - } else if ms == MOISTURE_SENSOR_3 { - return 3 - } else if ms == MOISTURE_SENSOR_4 { - return 4 - } else if ms == MOISTURE_SENSOR_5 { - return 5 - } else if ms == MOISTURE_SENSOR_6 { - return 6 - } - - return 0 -} - -func MoistureSensorFromId(id int) (MoistureSensor, error) { - if id == 1 { - return MOISTURE_SENSOR_1, nil - } else if id == 2 { - return MOISTURE_SENSOR_2, nil - } else if id == 3 { - return MOISTURE_SENSOR_3, nil - } else if id == 4 { - return MOISTURE_SENSOR_4, nil - } else if id == 5 { - return MOISTURE_SENSOR_5, nil - } else if id == 6 { - return MOISTURE_SENSOR_6, nil - } - - return MOISTURE_SENSOR_1, errors.New("invalid moisture sensor ID") -} diff --git a/arduino/water_outlet.go b/arduino/water_outlet.go deleted file mode 100644 index 8bb9d7f..0000000 --- a/arduino/water_outlet.go +++ /dev/null @@ -1,40 +0,0 @@ -package arduino - -import "errors" - -type WaterOutlet int - -const ( - WATER_OUTLET_1 WaterOutlet = iota - WATER_OUTLET_2 - WATER_OUTLET_3 - WATER_OUTLET_4 -) - -func (o WaterOutlet) OnCommand() (Command, error) { - if o == WATER_OUTLET_1 { - return WATER_1_ON, nil - } else if o == WATER_OUTLET_2 { - return WATER_2_ON, nil - } else if o == WATER_OUTLET_3 { - return WATER_3_ON, nil - } else if o == WATER_OUTLET_4 { - return WATER_4_ON, nil - } - - return WATER_OFF, errors.New("invalid outlet") -} - -func (o WaterOutlet) OffCommand() (Command, error) { - if o == WATER_OUTLET_1 { - return WATER_1_OFF, nil - } else if o == WATER_OUTLET_2 { - return WATER_2_OFF, nil - } else if o == WATER_OUTLET_3 { - return WATER_3_OFF, nil - } else if o == WATER_OUTLET_4 { - return WATER_4_OFF, nil - } - - return WATER_OFF, errors.New("invalid outlet") -} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..1875853 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + + "github.com/joho/godotenv" + "github.com/mewejo/go-watering/pkg/app" +) + +func main() { + if godotenv.Load() != nil { + log.Fatal("Error loading .env file") + } + + app.NewApp().Run() +} diff --git a/config/app_config.go b/config/app_config.go deleted file mode 100644 index 263198e..0000000 --- a/config/app_config.go +++ /dev/null @@ -1,68 +0,0 @@ -package config - -import ( - "github.com/mewejo/go-watering/arduino" - "github.com/mewejo/go-watering/world" -) - -type Application struct { - Zones []*Zone -} - -func GetApplication() Application { - a := Application{ - Zones: []*Zone{ - { - Id: "zone-1", - Name: "Zone 1", - TargetMoisture: world.MoistureLevel{ - Percentage: 70, - }, - MoistureSensors: []arduino.MoistureSensor{ - arduino.MOISTURE_SENSOR_1, - arduino.MOISTURE_SENSOR_2, - }, - WaterOutlets: []arduino.WaterOutlet{ - arduino.WATER_OUTLET_1, - }, - }, - { - Id: "zone-2", - Name: "Zone 2", - TargetMoisture: world.MoistureLevel{ - Percentage: 70, - }, - MoistureSensors: []arduino.MoistureSensor{ - arduino.MOISTURE_SENSOR_3, - arduino.MOISTURE_SENSOR_4, - }, - WaterOutlets: []arduino.WaterOutlet{ - arduino.WATER_OUTLET_2, - }, - }, - { - Id: "zone-3", - Name: "Zone 3", - TargetMoisture: world.MoistureLevel{ - Percentage: 70, - }, - MoistureSensors: []arduino.MoistureSensor{ - arduino.MOISTURE_SENSOR_5, - arduino.MOISTURE_SENSOR_6, - }, - WaterOutlets: []arduino.WaterOutlet{ - arduino.WATER_OUTLET_3, - }, - }, - { - Id: "patio", - Name: "Patio", - WaterOutlets: []arduino.WaterOutlet{ - arduino.WATER_OUTLET_4, - }, - }, - }, - } - - return a -} diff --git a/config/zone.go b/config/zone.go deleted file mode 100644 index b5df388..0000000 --- a/config/zone.go +++ /dev/null @@ -1,241 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "log" - "os" - "time" - - "github.com/mewejo/go-watering/arduino" - "github.com/mewejo/go-watering/helpers" - "github.com/mewejo/go-watering/homeassistant" - "github.com/mewejo/go-watering/world" -) - -type Zone struct { - Id string // This will be user defined, used for API calls - Name string - TargetMoisture world.MoistureLevel - MoistureSensors []arduino.MoistureSensor - WaterOutlets []arduino.WaterOutlet - MoisureReadings []arduino.MoistureReading - Watering bool - WateringChangedAt time.Time - ForcedWatering bool -} - -func (z Zone) GetHomeAssistantHumidifierConfiguration() homeassistant.HumidifierConfiguration { - c := homeassistant.NewZoneHumidifierConfiguration() - c.ObjectId = z.GetHomeAssistantObjectId() - c.UniqueId = z.GetHomeAssistantObjectId() - c.Name = z.Name + " Soaker" - c.StateTopic = z.GetHomeAssistantStateTopic() - c.CommandTopic = z.GetHomeAssistantCommandTopic() - c.TargetHumidityTopic = z.GetHomeAssistantTargetHumidityTopic() - c.TargetHumidityStateTopic = z.GetHomeAssistantTargetStateHumidityTopic() - c.AvailabilityTopic = z.GetHomeAssistantAvailabilityTopic() - c.ModeStateTopic = z.GetHomeAssistantModeStateTopic() - c.ModeCommandTopic = z.GetHomeAssistantModeCommandTopic() - c.Device = homeassistant.NewDeviceDetails() - - return c -} - -func (z Zone) GetHomeAssistantMoistureSensorConfiguration() homeassistant.MoistureSensorConfiguration { - c := homeassistant.NewMoistureSensorConfiguration() - c.Name = z.Name + " Moisture" - c.ObjectId = z.GetHomeAssistantObjectId() - c.UniqueId = z.GetHomeAssistantObjectId() - c.StateTopic = z.GetHomeAssistantStateTopic() - c.AvailabilityTopic = z.GetHomeAssistantAvailabilityTopic() - c.Device = homeassistant.NewDeviceDetails() - - return c -} - -func (z Zone) GetHomeAssistantHumidifierBaseTopic() string { - return fmt.Sprintf( - "%v/humidifier/%v", - os.Getenv("HOME_ASSISTANT_DISCOVERY_PREFIX"), - z.GetHomeAssistantObjectId(), - ) -} - -func (z Zone) GetHomeAssistantMoistureSensorBaseTopic() string { - return fmt.Sprintf( - "%v/sensor/%v/average-moisture", - os.Getenv("HOME_ASSISTANT_DISCOVERY_PREFIX"), - z.GetHomeAssistantObjectId(), - ) -} - -func (z Zone) GetHomeAssistantModeStateTopic() string { - return fmt.Sprintf("%v/mode_state", z.GetHomeAssistantHumidifierBaseTopic()) -} - -func (z Zone) GetHomeAssistantModeCommandTopic() string { - return fmt.Sprintf("%v/mode_command", z.GetHomeAssistantHumidifierBaseTopic()) -} - -func (z Zone) GetHomeAssistantAvailabilityTopic() string { - return fmt.Sprintf("%v/availability", z.GetHomeAssistantHumidifierBaseTopic()) -} - -func (z Zone) GetHomeAssistantCommandTopic() string { - return fmt.Sprintf("%v/command", z.GetHomeAssistantHumidifierBaseTopic()) -} - -func (z Zone) GetHomeAssistantTargetHumidityTopic() string { - return fmt.Sprintf("%v/target", z.GetHomeAssistantHumidifierBaseTopic()) -} - -func (z Zone) GetHomeAssistantTargetStateHumidityTopic() string { - return fmt.Sprintf("%v/humidity_state", z.GetHomeAssistantHumidifierBaseTopic()) -} - -func (z Zone) GetHomeAssistantStateTopic() string { - return fmt.Sprintf("%v/state", z.GetHomeAssistantHumidifierBaseTopic()) -} - -func (z Zone) GetHomeAssistantObjectId() string { - return os.Getenv("HOME_ASSISTANT_OBJECT_ID_PREFIX") + z.Id -} - -func (z *Zone) SetForcedWateringState(ard arduino.Arduino, state bool) { - if z.ForcedWatering == state { - return - } - - z.ForcedWatering = state - z.WateringChangedAt = time.Now() - - z.EnforceWateringState(ard) -} - -func (z *Zone) SetWaterState(ard arduino.Arduino, state bool) { - if z.Watering == state { - return - } - - z.Watering = state - z.WateringChangedAt = time.Now() - - z.EnforceWateringState(ard) -} - -func (z Zone) EnforceWateringState(ard arduino.Arduino) { - for _, outlet := range z.WaterOutlets { - err := ard.SetWaterState(outlet, z.Watering || z.ForcedWatering) - - if err != nil { - log.Fatal("could not set water state for zone") - } - } -} - -func (z Zone) ShouldStartWatering() (bool, error) { - moistureLevel, err := z.AverageMoistureLevel() - - if err != nil { - return false, errors.New("could not get average moisture level for zone") - } - - // Already watering... don't need to start - if z.Watering { - return false, nil - } - - if moistureLevel.Percentage < z.TargetMoisture.HysteresisOnLevel().Percentage { - return true, nil - } - - return false, nil -} - -func (z Zone) ShouldStopWatering() (bool, error) { - moistureLevel, err := z.AverageMoistureLevel() - - if err != nil { - return false, errors.New("could not get average moisture level for zone") - } - - // Not watering... don't need to stop - if !z.Watering { - return false, nil - } - - if moistureLevel.Percentage > z.TargetMoisture.HysteresisOffLevel().Percentage { - return true, nil - } - - return false, nil -} - -func (z Zone) AverageMoistureLevel() (world.MoistureLevel, error) { - // Loop over the readings until we have one from each sensor - readingsReversed := make([]arduino.MoistureReading, len(z.MoisureReadings)) - copy(readingsReversed, z.MoisureReadings) - helpers.ReverseSlice(readingsReversed) - - sensorsFound := []arduino.MoistureSensor{} - readings := []world.MoistureLevel{} - - for _, reading := range readingsReversed { - if moistureSensorInSlice(reading.Sensor, sensorsFound) { - continue - } - - sensorsFound = append(sensorsFound, reading.Sensor) - readings = append(readings, reading.Original) - - if len(sensorsFound) == len(z.MoistureSensors) { - break - } - } - - if len(readings) < 1 { - return world.MoistureLevel{}, errors.New("no readings to make average from") - } - - if len(sensorsFound) != len(z.MoistureSensors) { - return world.MoistureLevel{}, errors.New("incomplete data (sensors), cannot calculate moisture level") - } - - if len(readings) != len(z.MoistureSensors) { - return world.MoistureLevel{}, errors.New("incomplete data (readings), cannot calculate moisture level") - } - - var totalPercentage uint - - for _, reading := range readings { - totalPercentage += reading.Percentage - } - - return world.MoistureLevel{ - Percentage: uint(totalPercentage / uint(len(readings))), - }, nil -} - -func (z *Zone) RecordMoistureReading(r arduino.MoistureReading) { - z.MoisureReadings = append(z.MoisureReadings, r) - limitMoistureReadings(&z.MoisureReadings, 100) -} - -func limitMoistureReadings(s *[]arduino.MoistureReading, length int) { - if len(*s) <= length { - return - } - - *s = (*s)[len(*s)-length:] -} - -func moistureSensorInSlice(s arduino.MoistureSensor, sensors []arduino.MoistureSensor) bool { - for _, v := range sensors { - if v == s { - return true - } - } - - return false -} diff --git a/helpers/slices.go b/helpers/slices.go deleted file mode 100644 index c90953f..0000000 --- a/helpers/slices.go +++ /dev/null @@ -1,7 +0,0 @@ -package helpers - -func ReverseSlice[S ~[]E, E any](s S) { - for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { - s[i], s[j] = s[j], s[i] - } -} diff --git a/homeassistant/zone_configuration.go b/homeassistant/zone_configuration.go deleted file mode 100644 index 4b654cd..0000000 --- a/homeassistant/zone_configuration.go +++ /dev/null @@ -1,84 +0,0 @@ -package homeassistant - -import "github.com/mewejo/go-watering/world" - -type HumidifierConfiguration struct { - StateTopic string `json:"state_topic"` - DeviceClass string `json:"device_class"` - Name string `json:"name"` - ObjectId string `json:"object_id"` - UniqueId string `json:"unique_id"` - CommandTopic string `json:"command_topic"` - TargetHumidityTopic string `json:"target_humidity_command_topic"` - TargetHumidityStateTopic string `json:"target_humidity_state_topic"` - AvailabilityTopic string `json:"availability_topic"` - Device DeviceDetails `json:"device"` - PayloadAvailable string `json:"payload_available"` - PayloadNotAvailable string `json:"payload_not_available"` - PayloadOn string `json:"payload_on"` - PayloadOff string `json:"payload_off"` - Optimistic bool `json:"optimistic"` - StateValueTemplate string `json:"state_value_template"` - ModeCommandTopic string `json:"mode_command_topic"` - ModeStateTopic string `json:"mode_state_topic"` - Modes []string `json:"modes"` -} - -type MoistureSensorConfiguration struct { - Name string `json:"name"` - DeviceClass string `json:"device_class"` - ObjectId string `json:"object_id"` - UniqueId string `json:"unique_id"` - StateTopic string `json:"state_topic"` - StateValueTemplate string `json:"value_template"` - AvailabilityTopic string `json:"availability_topic"` - UnitOfMeasurement string `json:"unit_of_measurement"` - Device DeviceDetails `json:"device"` - PayloadAvailable string `json:"payload_available"` - PayloadNotAvailable string `json:"payload_not_available"` -} - -type DeviceDetails struct { - Identifier string `json:"identifiers"` - Name string `json:"name"` - Model string `json:"model"` - Manufacturer string `json:"manufacturer"` -} - -type ZoneState struct { - MoistureLevel world.MoistureLevel `json:"moisture"` - State string `json:"state"` -} - -func NewDeviceDetails() DeviceDetails { - d := DeviceDetails{} - d.Manufacturer = "Josh Bonfield" - d.Model = "Go Watering" - d.Identifier = "vegetable-soaker" - d.Name = "Vegetable Soaker" - - return d -} - -func NewMoistureSensorConfiguration() MoistureSensorConfiguration { - c := MoistureSensorConfiguration{} - c.DeviceClass = "moisture" - c.StateValueTemplate = "{{ value_json.moisture.percentage }}" - c.UnitOfMeasurement = "%" - c.PayloadAvailable = "online" - c.PayloadNotAvailable = "offline" - - return c -} - -func NewZoneHumidifierConfiguration() HumidifierConfiguration { - c := HumidifierConfiguration{} - c.DeviceClass = "humidifier" - c.PayloadAvailable = "online" - c.PayloadNotAvailable = "offline" - c.PayloadOn = "on" - c.PayloadOff = "off" - c.StateValueTemplate = "{{ value_json.state }}" - c.Modes = []string{"normal"} - return c -} diff --git a/main.go b/main.go deleted file mode 100644 index 5e9d7f1..0000000 --- a/main.go +++ /dev/null @@ -1,198 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "os/signal" - "syscall" - "time" - - "github.com/joho/godotenv" - - mqttLib "github.com/eclipse/paho.mqtt.golang" - "github.com/mewejo/go-watering/api" - "github.com/mewejo/go-watering/arduino" - "github.com/mewejo/go-watering/config" - "github.com/mewejo/go-watering/mqtt" -) - -var mqttClient mqttLib.Client - -func main() { - - if godotenv.Load() != nil { - log.Fatal("Error loading .env file") - } - - app := config.GetApplication() - - fmt.Println("Connecting to MQTT broker...") - - mqttClient = mqtt.GetClient() - - fmt.Println("Connected to MQTT!") - - fmt.Println("Publishing Home Assistant auto discovery...") - - for _, zone := range app.Zones { - mqtt.PublishHomeAsssitantAutoDiscovery(mqttClient, *zone, []arduino.MoistureSensor{ - arduino.MOISTURE_SENSOR_1, - arduino.MOISTURE_SENSOR_2, - arduino.MOISTURE_SENSOR_3, - arduino.MOISTURE_SENSOR_4, - arduino.MOISTURE_SENSOR_5, - arduino.MOISTURE_SENSOR_6, - }) - - mqtt.PublishHomeAssistantAvailability(mqttClient, *zone) - - token, _ := mqtt.PublishHomeAssistantState(mqttClient, *zone) - - if token != nil { - token.Wait() - } - - mqtt.PublishHomeAssistantTargetHumidity(mqttClient, *zone).Wait() - mqtt.PublishHomeAssistantModeState(mqttClient, *zone).Wait() - } - - fmt.Println("Waiting until Arduino is ready...") - - ard := arduino.GetArduino() - - ard.WaitUntilReady() - - fmt.Println("The Arduino is ready!") - - setupCloseHandler(ard) - - go maintainMoistureLevels(ard, &app) - go readMoistureLevels(ard, &app) - go enforceZoneWaterOutletStates(ard, &app) - - fmt.Println("Starting API...") - - api.StartApi(&app) -} - -func setupCloseHandler(ard arduino.Arduino) { - c := make(chan os.Signal) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - go func() { - <-c - fmt.Println("Exiting... turning water off") - ard.SendCommand(arduino.WATER_OFF) - ard.Port.Close() - os.Exit(0) - }() -} - -func enforceZoneWaterOutletStates(ard arduino.Arduino, app *config.Application) { - ticker := time.NewTicker(60 * time.Second) - - quit := make(chan struct{}) - - go func() { - for { - select { - case <-ticker.C: - for _, zone := range app.Zones { - zone.EnforceWateringState(ard) - } - - case <-quit: - ticker.Stop() - return - } - } - }() -} - -func maintainMoistureLevels(ard arduino.Arduino, app *config.Application) { - ticker := time.NewTicker(10 * time.Second) - - quit := make(chan struct{}) - - go func() { - for { - select { - case <-ticker.C: - for _, zone := range app.Zones { - if len(zone.MoistureSensors) < 1 { - continue - } - - shouldNotBeWatering, err := zone.ShouldStopWatering() - - if err != nil { - continue - } - - if shouldNotBeWatering { - zone.SetWaterState(ard, false) - continue - } - - shouldStartWatering, err := zone.ShouldStartWatering() - - if err != nil { - continue - } - - if shouldStartWatering { - zone.SetWaterState(ard, true) - continue - } - } - - case <-quit: - ticker.Stop() - return - } - } - }() -} - -func readMoistureLevels(ard arduino.Arduino, app *config.Application) { - ticker := time.NewTicker(2 * time.Second) - - quit := make(chan struct{}) - - go func() { - for { - select { - case <-ticker.C: - readings, err := ard.GetReadings() - - if err != nil { - log.Fatal("Could not get readings from Arduino: " + err.Error()) - } - - processMoistureReadings(app, readings) - case <-quit: - ticker.Stop() - return - } - } - }() -} - -func processMoistureReadings(app *config.Application, readings []arduino.MoistureReading) { - for _, zone := range app.Zones { - for _, sensor := range zone.MoistureSensors { - for _, reading := range readings { - if sensor != reading.Sensor { - continue - } - - // This 3 level nesting feels nasty - - zone.RecordMoistureReading(reading) - mqtt.PublishHomeAssistantState(mqttClient, *zone) - mqtt.PublishHomeAssistantAvailability(mqttClient, *zone) - mqtt.PublishHomeAssistantTargetHumidity(mqttClient, *zone) - } - } - } -} diff --git a/mqtt/client.go b/mqtt/client.go deleted file mode 100644 index 91dc74c..0000000 --- a/mqtt/client.go +++ /dev/null @@ -1,165 +0,0 @@ -package mqtt - -import ( - "encoding/json" - "fmt" - "log" - "os" - "time" - - mqtt "github.com/eclipse/paho.mqtt.golang" - "github.com/mewejo/go-watering/arduino" - "github.com/mewejo/go-watering/config" - "github.com/mewejo/go-watering/homeassistant" -) - -var f mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) { - fmt.Printf("TOPIC: %s\n", msg.Topic()) - fmt.Printf("MSG: %s\n", msg.Payload()) -} - -func PublishHomeAssistantModeState(c mqtt.Client, zone config.Zone) mqtt.Token { - return c.Publish( - zone.GetHomeAssistantModeStateTopic(), - 0, - false, - "normal", // TODO - ) -} - -func PublishHomeAssistantTargetHumidity(c mqtt.Client, zone config.Zone) mqtt.Token { - return c.Publish( - zone.GetHomeAssistantTargetStateHumidityTopic(), - 0, - false, - fmt.Sprintf("%v", zone.TargetMoisture.Percentage), - ) -} - -func PublishHomeAssistantAvailability(c mqtt.Client, zone config.Zone) mqtt.Token { - return c.Publish( - zone.GetHomeAssistantAvailabilityTopic(), - 0, - false, - "online", - ) -} - -func PublishHomeAssistantState(c mqtt.Client, zone config.Zone) (mqtt.Token, error) { - - moistureLevel, err := zone.AverageMoistureLevel() - - if err != nil { - return nil, err - } - - state := homeassistant.ZoneState{} - state.State = "on" // TODO - state.MoistureLevel = moistureLevel - - json, err := json.Marshal(state) - - if err != nil { - return nil, err - } - - return c.Publish( - zone.GetHomeAssistantStateTopic(), - 0, - true, - json, - ), nil -} - -func PublishHomeAsssitantAutoDiscovery(c mqtt.Client, zone config.Zone, moistureSensors []arduino.MoistureSensor) { - - var topic string - var err error - var zoneConfigJson []byte - var token mqtt.Token - - // Main device - topic = fmt.Sprintf( - "%v/config", - zone.GetHomeAssistantHumidifierBaseTopic(), - ) - - zoneConfigJson, err = json.Marshal( - zone.GetHomeAssistantHumidifierConfiguration(), - ) - - if err != nil { - log.Fatal("Could not create Home Assistant config for zone humidifier: " + zone.Id) - } - - token = c.Publish(topic, 1, true, zoneConfigJson) - token.Wait() - - // No sensors, no average! - if len(zone.MoistureSensors) < 1 { - return - } - - // Now the average sensor - topic = fmt.Sprintf( - "%v/config", - zone.GetHomeAssistantMoistureSensorBaseTopic(), - ) - - zoneConfigJson, err = json.Marshal( - zone.GetHomeAssistantMoistureSensorConfiguration(), - ) - - if err != nil { - log.Fatal("Could not create Home Assistant config for zone average moisture sensor: " + zone.Id) - } - - token = c.Publish(topic, 1, true, zoneConfigJson) - token.Wait() - - // Now the sensors on their own - for _, sensor := range moistureSensors { - topic = fmt.Sprintf( - "%v/config", - sensor.GetHomeAssistantBaseTopic(), - ) - - sensorConfigJson, err := json.Marshal( - sensor.GetHomeAssistantMoistureSensorConfiguration(), - ) - - if err != nil { - log.Fatal( - fmt.Sprintf("Could not create Home Assistant config for zone moisture sensor: %v", sensor.GetId()), - ) - } - - token = c.Publish(topic, 1, true, sensorConfigJson) - token.Wait() - } -} - -func GetClient() mqtt.Client { - connectionString := fmt.Sprintf( - "tcp://%v:%v", - os.Getenv("MQTT_HOST"), - os.Getenv("MQTT_PORT"), - ) - - opts := mqtt.NewClientOptions() - opts.AddBroker(connectionString) - opts.SetClientID(os.Getenv("MQTT_CLIENT_ID")) - opts.SetUsername(os.Getenv("MQTT_USERNAME")) - opts.SetPassword(os.Getenv("MQTT_PASSWORD")) - opts.SetKeepAlive(2 * time.Second) - opts.SetDefaultPublishHandler(f) - opts.SetPingTimeout(1 * time.Second) - - c := mqtt.NewClient(opts) - - if token := c.Connect(); token.Wait() && token.Error() != nil { - panic(token.Error()) - } - - return c -} diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 0000000..36924dd --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,95 @@ +package app + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/mewejo/go-watering/pkg/arduino" + "github.com/mewejo/go-watering/pkg/hass" + "github.com/mewejo/go-watering/pkg/model" +) + +type App struct { + zones []*model.Zone + waterOutlets []*model.WaterOutlet + moistureSensors []*model.MoistureSensor + hass *hass.HassClient + hassDevice *model.HassDevice + arduino *arduino.Arduino + debug bool +} + +func (app *App) setupCloseHandler() <-chan os.Signal { + sigChan := make(chan os.Signal) + + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + return sigChan +} + +func (app *App) Run() { + + app.debug = os.Getenv("APP_DEBUG") == "true" + + app.configureHardware() + + err := app.setupHass() + + if err != nil { + panic(err) + } + + err = app.publishHassAutoDiscovery() + + if err != nil { + panic(err) + } + + app.startHassAvailabilityTimer() + app.sendInitialZoneModesToHass() + + osExit := app.setupCloseHandler() + + closeArduinoChan, arduinoInputChan := app.initialiseArduino() + arduinoHeartbeatStoppedChan, stopMonitoringArduinoHeartbeatChan := app.monitorArduinoHeartbeat() + stopRequestingOutletStatesChan := app.startRequestingWaterOutletStates() + stopRequestingMoistureSensorReadingsChan := app.startRequestingMoistureSensorReadings() + stopSendingOutletStatesToArduinoChan := app.startSendingWaterStatesToArduino() + stopSendingMoistureSensorReadingsToHassChan := app.startSendingMoistureSensorReadingsToHass() + stopSendingZoneStatesToHassChan := app.startSendingZoneStateToHass() + stopRegulatingZonesChan := app.regulateZones() + + app.listenForWaterOutletCommands() + app.listenForZoneCommands() + + go app.handleArduinoDataInput(arduinoInputChan) + + doExit := func(code int) { + close(stopMonitoringArduinoHeartbeatChan) + close(stopRequestingOutletStatesChan) + app.forceSetAllWaterOutletStates(false) + close(stopSendingOutletStatesToArduinoChan) + close(stopRequestingMoistureSensorReadingsChan) + close(stopSendingMoistureSensorReadingsToHassChan) + close(closeArduinoChan) + close(stopSendingZoneStatesToHassChan) + close(stopRegulatingZonesChan) + app.markHassNotAvailable() + app.hass.Disconnect() + os.Exit(code) + } + + select { + case <-osExit: + doExit(0) + case <-arduinoHeartbeatStoppedChan: + log.Println("did not receive heartbeat from Arduino in time") + doExit(1) + } +} + +func NewApp() *App { + return &App{} +} diff --git a/pkg/app/arduino.go b/pkg/app/arduino.go new file mode 100644 index 0000000..e25fb09 --- /dev/null +++ b/pkg/app/arduino.go @@ -0,0 +1,213 @@ +package app + +import ( + "errors" + "log" + "time" + + "github.com/mewejo/go-watering/pkg/arduino" + "github.com/mewejo/go-watering/pkg/model" + "github.com/mewejo/go-watering/pkg/persistence" +) + +func (app *App) monitorArduinoHeartbeat() (<-chan bool, chan bool) { + ticker := time.NewTicker(1 * time.Second) + deadArduino := make(chan bool) + closeTimer := make(chan bool) + + go func() { + for { + select { + case <-ticker.C: + if app.arduino.LastHeartbeat.Time.IsZero() { + continue + } + + cutOff := time.Now().Add(-time.Minute) + + if app.arduino.LastHeartbeat.Time.Before(cutOff) { + deadArduino <- true + return + } + + case <-closeTimer: + ticker.Stop() + return + } + } + }() + + return deadArduino, closeTimer +} + +func (app *App) initialiseArduino() (chan bool, <-chan string) { + app.arduino = arduino.NewArduino() + + if app.arduino.FindAndOpenPort() != nil { + panic("could not find or open Arduino port") + } + + closeChan := make(chan bool) + dataChan := make(chan string, 500) + + go func() { + { + <-closeChan + app.arduino.ClosePort() + return + } + }() + + go func() { + for { + select { + case <-closeChan: + return + default: + line, err := app.arduino.ReadLine() + + if err != nil { + continue + } + + dataChan <- line + } + } + }() + + return closeChan, dataChan +} + +func (app *App) startSendingWaterStatesToArduino() chan bool { + ticker := time.NewTicker(1 * time.Second) + + quit := make(chan bool) + + go func() { + for { + select { + case <-ticker.C: + for _, outlet := range app.waterOutlets { + go app.arduino.SetWaterOutletState(outlet) + } + case <-quit: + ticker.Stop() + return + } + } + }() + + return quit +} + +func (app *App) startRequestingWaterOutletStates() chan bool { + ticker := time.NewTicker(1 * time.Second) + + quit := make(chan bool) + + go func() { + for { + select { + case <-ticker.C: + app.arduino.SendCommand(arduino.REQUEST_OUTLETS) + case <-quit: + ticker.Stop() + return + } + } + }() + + return quit +} + +func (app *App) startRequestingMoistureSensorReadings() chan bool { + ticker := time.NewTicker(1 * time.Second) + + quit := make(chan bool) + + go func() { + for { + select { + case <-ticker.C: + app.arduino.SendCommand(arduino.REQUEST_READINGS) + case <-quit: + ticker.Stop() + return + } + } + }() + + return quit +} + +func (app *App) findMoistureSensorById(id uint) (*model.MoistureSensor, error) { + for _, sensor := range app.moistureSensors { + if sensor.Id != id { + continue + } + + return sensor, nil + } + + return &model.MoistureSensor{}, errors.New("could not find sensor by ID") +} + +func (app *App) handleArduinoDataInput(dataChan <-chan string) { + + handleHeartbeat := func(hb model.ArduinoHeartbeat) { + app.arduino.LastHeartbeat = hb + } + + handleMoistureReading := func(reading model.MoistureReading, sensorId uint) { + if app.debug { + log.Printf( + "Sensor ID %d - Raw value: %d", + sensorId, + reading.Raw, + ) + } + + persistence.RecordMoistureReading(sensorId, reading) + } + + handleWaterOutletState := func(outletId uint, actualState bool, targetState bool) { + for _, outlet := range app.waterOutlets { + if outlet.Id != outletId { + continue + } + + outlet.ActualState = actualState + app.sendWaterOutletStateToHass(outlet) + } + } + + for line := range dataChan { + heartbeat, err := model.MakeArduinoHeartbeatFromString(line) + + if err == nil { + go handleHeartbeat(heartbeat) + continue + } + + moistureReading, sensorId, err := model.MakeMoistureReadingFromString(line) + + if err == nil { + + sensor, err := app.findMoistureSensorById(sensorId) + + if err == nil { + moistureReading.CalculateMoistureLevelForSensor(sensor) + go handleMoistureReading(moistureReading, sensorId) + } + + continue + } + + outletId, actualState, targetState, err := model.DecodeWaterOutletStateFromString(line) + + if err == nil { + go handleWaterOutletState(outletId, actualState, targetState) + continue + } + } +} diff --git a/pkg/app/hardware.go b/pkg/app/hardware.go new file mode 100644 index 0000000..b31fe70 --- /dev/null +++ b/pkg/app/hardware.go @@ -0,0 +1,97 @@ +package app + +import ( + "os" + "strconv" + + "github.com/mewejo/go-watering/pkg/arduino" + "github.com/mewejo/go-watering/pkg/model" +) + +// Set the states and immediately call the Arduino to action it +func (app *App) forceSetAllWaterOutletStates(state bool) { + for _, outlet := range app.waterOutlets { + outlet.TargetState = state // So they don't somehow get changed, as we're using the bulk command + + // Using the bulk command to do it quickly. + // If this method is used, it's likely on shut down + if state { + app.arduino.SendCommand(arduino.WATER_ON) + } else { + app.arduino.SendCommand(arduino.WATER_OFF) + } + } +} + +func (app *App) configureHardware() { + + waterOutlet1 := model.NewWaterOutlet(1, "Soaker hose #1", false) + waterOutlet2 := model.NewWaterOutlet(2, "Soaker hose #2", false) + waterOutlet3 := model.NewWaterOutlet(3, "Soaker hose #3", false) + waterOutlet4 := model.NewWaterOutlet(4, "Soaker hose #4", true) + + app.waterOutlets = append(app.waterOutlets, waterOutlet1) + app.waterOutlets = append(app.waterOutlets, waterOutlet2) + app.waterOutlets = append(app.waterOutlets, waterOutlet3) + app.waterOutlets = append(app.waterOutlets, waterOutlet4) + + moistureSensor1 := model.NewMoistureSensor(1, "Sensor #1") + moistureSensor2 := model.NewMoistureSensor(2, "Sensor #2") + moistureSensor3 := model.NewMoistureSensor(3, "Sensor #3") + moistureSensor4 := model.NewMoistureSensor(4, "Sensor #4") + moistureSensor5 := model.NewMoistureSensor(5, "Sensor #5") + moistureSensor6 := model.NewMoistureSensor(6, "Sensor #6") + + app.moistureSensors = append(app.moistureSensors, moistureSensor1) + app.moistureSensors = append(app.moistureSensors, moistureSensor2) + app.moistureSensors = append(app.moistureSensors, moistureSensor3) + app.moistureSensors = append(app.moistureSensors, moistureSensor4) + app.moistureSensors = append(app.moistureSensors, moistureSensor5) + app.moistureSensors = append(app.moistureSensors, moistureSensor6) + + for _, moistureSensor := range app.moistureSensors { + dryThreshold := os.Getenv("MOISTURE_SENSOR_" + moistureSensor.IdAsString() + "_DRY") + wetThreshold := os.Getenv("MOISTURE_SENSOR_" + moistureSensor.IdAsString() + "_WET") + + if dryThreshold != "" { + threshold, err := strconv.Atoi(dryThreshold) + + if err != nil { + panic("could not get dry threshold for sensor ID " + moistureSensor.IdAsString() + ": " + err.Error()) + } + + moistureSensor.DryThreshold = uint(threshold) + } + + if wetThreshold != "" { + threshold, err := strconv.Atoi(wetThreshold) + + if err != nil { + panic("could not get wet threshold for sensor ID " + moistureSensor.IdAsString() + ": " + err.Error()) + } + + moistureSensor.WetThreshold = uint(threshold) + } + } + + app.zones = append(app.zones, model.NewZone( + "raised-bed-1", + "Raised Bed #1", + []*model.MoistureSensor{moistureSensor1, moistureSensor2}, + []*model.WaterOutlet{waterOutlet1}, + )) + + app.zones = append(app.zones, model.NewZone( + "raised-bed-2", + "Raised Bed #2", + []*model.MoistureSensor{moistureSensor3, moistureSensor4}, + []*model.WaterOutlet{waterOutlet2}, + )) + + app.zones = append(app.zones, model.NewZone( + "raised-bed-3", + "Raised Bed #3", + []*model.MoistureSensor{moistureSensor5, moistureSensor6}, + []*model.WaterOutlet{waterOutlet3}, + )) +} diff --git a/pkg/app/hass.go b/pkg/app/hass.go new file mode 100644 index 0000000..7689136 --- /dev/null +++ b/pkg/app/hass.go @@ -0,0 +1,338 @@ +package app + +import ( + "encoding/json" + "os" + "strconv" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/mewejo/go-watering/pkg/constants" + "github.com/mewejo/go-watering/pkg/hass" + "github.com/mewejo/go-watering/pkg/model" + "github.com/mewejo/go-watering/pkg/persistence" +) + +func (app *App) listenForWaterOutletCommands() { + + subscribe := func(outlet *model.WaterOutlet) { + app.hass.Subscribe( + outlet.MqttCommandTopic(app.hassDevice), + func(message mqtt.Message) { + if string(message.Payload()) == constants.HASS_STATE_ON { + outlet.TargetState = true + } else if string(message.Payload()) == constants.HASS_STATE_OFF { + outlet.TargetState = false + } + + app.arduino.SetWaterOutletState(outlet) + }, + ) + } + + for _, outlet := range app.waterOutlets { + if !outlet.IndependentlyControlled { + continue + } + + subscribe(outlet) + } +} + +func (app *App) listenForZoneCommands() { + + subscribe := func(zone *model.Zone) { + app.hass.Subscribe( + zone.MqttCommandTopic(app.hassDevice), + func(message mqtt.Message) { + if string(message.Payload()) == constants.HASS_STATE_ON { + zone.Enabled = true + } else if string(message.Payload()) == constants.HASS_STATE_OFF { + zone.Enabled = false + } + + app.sendZoneStateToHass(zone) + }, + ) + + app.hass.Subscribe( + zone.MqttTargetMoistureCommandTopic(app.hassDevice), + func(message mqtt.Message) { + moisturePercent, err := strconv.Atoi(string(message.Payload())) + + if err != nil { + return + } + + zone.TargetMoisture = model.MakeMoistureLevel(uint(moisturePercent)) + app.sendZoneStateToHass(zone) + }, + ) + + app.hass.Subscribe( + zone.MqttModeCommandTopic(app.hassDevice), + func(message mqtt.Message) { + mode, err := model.GetZoneModeFromKey( + string(message.Payload()), + ) + + if err != nil { + return + } + + zone.Mode = mode + app.sendZoneStateToHass(zone) + }, + ) + } + + for _, zone := range app.zones { + subscribe(zone) + } +} + +func (app *App) sendWaterOutletStateToHass(outlet *model.WaterOutlet) error { + + payload, err := json.Marshal(outlet) + + if err != nil { + return err + } + + app.hass.Publish( + hass.MakeMqttMessage( + outlet.MqttStateTopic(app.hassDevice), + string(payload), + ), + ) + + return nil +} + +func (app *App) sendInitialZoneModesToHass() { + for _, zone := range app.zones { + app.sendZoneModeCommandToHass(zone, model.GetDefaultZoneMode()) + } +} + +func (app *App) sendZoneModeCommandToHass(zone *model.Zone, mode *model.ZoneMode) { + app.hass.Publish( + hass.MakeMqttMessage( + zone.MqttModeCommandTopic(app.hassDevice), + mode.Key, + ).Retained().Qos(2), + ) +} + +func (app *App) sendZoneStateToHass(zone *model.Zone) error { + + average, err := persistence.GetAverageReadingForSensorsSince(zone.MoistureSensors, 2*time.Minute) + + if err != nil { + return err + } + + state := model.MakeZoneHassState( + zone, + average, + ) + + payload, err := json.Marshal(state) + + if err != nil { + return err + } + + app.hass.Publish( + hass.MakeMqttMessage( + zone.MqttStateTopic(app.hassDevice), + string(payload), + ), + ) + + return nil + +} + +func (app *App) startSendingZoneStateToHass() chan bool { + ticker := time.NewTicker(1 * time.Second) + + quit := make(chan bool) + + go func() { + for { + select { + case <-ticker.C: + for _, zone := range app.zones { + go app.sendZoneStateToHass(zone) + } + case <-quit: + ticker.Stop() + return + } + } + }() + + return quit +} + +func (app *App) startSendingMoistureSensorReadingsToHass() chan bool { + + ticker := time.NewTicker(5 * time.Second) + + quit := make(chan bool) + + sendSensorStates := func() { + for _, sensor := range app.moistureSensors { + go app.publishMoistureSensorStateToHass(sensor) + } + } + + go func() { + for { + select { + case <-ticker.C: + go sendSensorStates() + + case <-quit: + ticker.Stop() + return + } + } + }() + + return quit +} + +func (app *App) publishMoistureSensorStateToHass(sensor *model.MoistureSensor) error { + + moistureLevel, err := persistence.GetAverageReadingForSensorIdSince(sensor.Id, 2*time.Minute) + + if err != nil { + return err + } + + state := model.MoistureSensorHassState{ + Sensor: sensor, + MoistureLevel: moistureLevel, + } + + payload, err := json.Marshal(state) + + if err != nil { + return err + } + + app.hass.Publish( + hass.MakeMqttMessage( + sensor.MqttStateTopic(app.hassDevice), + string(payload), + ), + ) + + return nil +} + +func (app *App) markHassNotAvailable() { + app.hass.Publish( + hass.MakeMqttMessage( + app.hassDevice.GetFqAvailabilityTopic(), + app.hassDevice.PayloadNotAvailable, + ), + ).Wait() +} + +func (app *App) startHassAvailabilityTimer() chan bool { + ticker := time.NewTicker(5 * time.Second) + + quit := make(chan bool) + + sendAvailableMessage := func() { + app.hass.Publish( + hass.MakeMqttMessage( + app.hassDevice.GetFqAvailabilityTopic(), + app.hassDevice.PayloadAvailable, + ), + ) + } + + sendAvailableMessage() + + go func() { + for { + select { + case <-ticker.C: + sendAvailableMessage() + + case <-quit: + ticker.Stop() + return + } + } + }() + + return quit +} + +func (app *App) setupHass() error { + + app.hassDevice = model.NewHassDevice() + + app.hass = hass.NewClient( + os.Getenv("HOME_ASSISTANT_DISCOVERY_PREFIX"), + app.hassDevice, + ) + + return app.hass.Connect( + hass.MakeMqttMessage(app.hassDevice.GetFqAvailabilityTopic(), app.hassDevice.PayloadNotAvailable), + ) +} + +func (app *App) publishHassAutoDiscovery() error { + for _, entity := range app.moistureSensors { + token, err := app.hass.PublishAutoDiscovery(entity) + + if err != nil { + return err + } + + token.Wait() + } + + for _, entity := range app.waterOutlets { + + if !entity.IndependentlyControlled { + continue + } + + token, err := app.hass.PublishAutoDiscovery(entity) + + if err != nil { + return err + } + + token.Wait() + } + + for _, entity := range app.zones { + token, err := app.hass.PublishAutoDiscovery(entity) + + if err != nil { + return err + } + + token.Wait() + } + + for _, entity := range app.zones { + token, err := app.hass.PublishAutoDiscovery(entity.AverageMoistureSensor) + + if err != nil { + return err + } + + token.Wait() + } + + return nil +} diff --git a/pkg/app/manage_zones.go b/pkg/app/manage_zones.go new file mode 100644 index 0000000..ec7292c --- /dev/null +++ b/pkg/app/manage_zones.go @@ -0,0 +1,91 @@ +package app + +import ( + "log" + "time" + + "github.com/mewejo/go-watering/pkg/model" + "github.com/mewejo/go-watering/pkg/persistence" +) + +func (app *App) regulateZones() chan bool { + ticker := time.NewTicker(1 * time.Second) + + quit := make(chan bool) + + handleZone := func(zone *model.Zone) { + if err := app.regulateZone(zone); err != nil { + log.Println("regulating zone " + zone.Name + ": " + err.Error()) + } + + app.preventZoneFlooding(zone) + app.ensureZoneWaterOutletState(zone) + } + + go func() { + for { + select { + case <-ticker.C: + for _, zone := range app.zones { + go handleZone(zone) + } + case <-quit: + ticker.Stop() + return + } + } + }() + + return quit +} + +func (app *App) ensureZoneWaterOutletState(zone *model.Zone) { + for _, outlet := range zone.WaterOutlets { + outlet.TargetState = zone.WaterOutletsState + } +} + +func (app *App) preventZoneFlooding(zone *model.Zone) { + if !zone.WaterOutletsState { + return + } + + cutoff := time.Now().Add(-zone.Mode.CutOffDuration) + + if zone.WaterOutletsStateChangedAt.Before(cutoff) { + zone.Mode = model.GetDefaultZoneMode() + zone.SetWaterOutletsState(false) + } +} + +func (app *App) regulateZone(zone *model.Zone) error { + if !zone.Enabled { + zone.SetWaterOutletsState(false) + return nil + } + + if zone.Mode == model.GetDefaultZoneMode() { + averageMoisture, err := persistence.GetAverageReadingForSensorsSince(zone.MoistureSensors, 2*time.Minute) + + if err != nil { + return err + } + + var hysteresis int = 5 + + averageMoisturePercent := int(averageMoisture.Percentage) + targetMoisturePercent := int(zone.TargetMoisture.Percentage) + + if averageMoisturePercent < (targetMoisturePercent - hysteresis) { + zone.SetWaterOutletsState(true) + return nil + } else if averageMoisturePercent > (targetMoisturePercent + hysteresis) { + zone.SetWaterOutletsState(false) + return nil + } + } else { + zone.SetWaterOutletsState(true) + } + + return nil +} diff --git a/pkg/arduino/arduino.go b/pkg/arduino/arduino.go new file mode 100644 index 0000000..852b744 --- /dev/null +++ b/pkg/arduino/arduino.go @@ -0,0 +1,135 @@ +package arduino + +import ( + "errors" + "strings" + + "github.com/mewejo/go-watering/pkg/model" + "go.bug.st/serial" +) + +type Arduino struct { + port serial.Port + LastHeartbeat model.ArduinoHeartbeat +} + +func (a *Arduino) SetWaterOutletState(outlet *model.WaterOutlet) { + var command Command + + if outlet.TargetState { + switch outlet.Id { + case 1: + command = WATER_1_ON + case 2: + command = WATER_2_ON + case 3: + command = WATER_3_ON + case 4: + command = WATER_4_ON + } + } else { + switch outlet.Id { + case 1: + command = WATER_1_OFF + case 2: + command = WATER_2_OFF + case 3: + command = WATER_3_OFF + case 4: + command = WATER_4_OFF + } + } + + a.SendCommand(command) +} + +func (a Arduino) SendCommand(command Command) (int, error) { + return a.port.Write([]byte(command)) +} + +func (a Arduino) ReadData(buffer []byte) (int, error) { + return a.port.Read(buffer) +} + +func findArduinoPort() (string, error) { + ports, err := serial.GetPortsList() + + if err != nil { + return "", err + } + + if len(ports) == 0 { + return "", errors.New("no serial ports found!") + } + + for _, port := range ports { + if !strings.Contains(port, "ttyACM") { + continue + } + + return port, nil + } + + return "", errors.New("no devices found which look like an Arduino") +} + +func (a Arduino) ReadLine() (string, error) { + buff := make([]byte, 1) + data := "" + + for { + n, err := a.ReadData(buff) + + if err != nil { + return "", err + } + + if n == 0 { + break + } + + data += string(buff[:n]) + + if strings.Contains(data, "\n") { + break + } + } + + data = strings.TrimSuffix(data, "\n") + data = strings.TrimSuffix(data, "\r") + + return data, nil +} + +func (a *Arduino) ClosePort() error { + return a.port.Close() +} + +func (a *Arduino) FindAndOpenPort() error { + arduinoPort, err := findArduinoPort() + + if err != nil { + return err + } + + mode := &serial.Mode{ + BaudRate: 9600, + } + + port, err := serial.Open(arduinoPort, mode) + + if err != nil { + return err + } + + a.port = port + + return nil +} + +func NewArduino() *Arduino { + + return &Arduino{ + LastHeartbeat: model.ArduinoHeartbeat{}, + } +} diff --git a/arduino/commands.go b/pkg/arduino/command.go similarity index 100% rename from arduino/commands.go rename to pkg/arduino/command.go diff --git a/pkg/constants/hass_state.go b/pkg/constants/hass_state.go new file mode 100644 index 0000000..23dad3e --- /dev/null +++ b/pkg/constants/hass_state.go @@ -0,0 +1,4 @@ +package constants + +const HASS_STATE_ON = "on" +const HASS_STATE_OFF = "off" diff --git a/pkg/hass/hass_client.go b/pkg/hass/hass_client.go new file mode 100644 index 0000000..c120839 --- /dev/null +++ b/pkg/hass/hass_client.go @@ -0,0 +1,101 @@ +package hass + +import ( + "encoding/json" + "fmt" + "os" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/mewejo/go-watering/pkg/model" +) + +type HassClient struct { + client mqtt.Client + Namespace string // prefix all MQTT topics with this NS + Device *model.HassDevice +} + +func NewClient(namespace string, device *model.HassDevice) *HassClient { + return &HassClient{ + Namespace: namespace, + Device: device, + } +} + +func (c *HassClient) defaultMessageHandler(msg mqtt.Message) { + // TODO + fmt.Printf("TOPIC: %s\n", msg.Topic()) + fmt.Printf("MSG: %s\n", msg.Payload()) +} + +func (c *HassClient) PublishAutoDiscovery(entity model.HassAutoDiscoverable) (mqtt.Token, error) { + + payload := entity.AutoDiscoveryPayload(c.Device).WithGlobalTopicPrefix(c.Namespace, c.Device, entity) + + json, err := json.Marshal( + payload, + ) + + if err != nil { + return nil, err + } + + return c.Publish(MakeMqttMessage( + entity.MqttTopic(c.Device)+"/config", + string(json), + )), nil +} + +func (c *HassClient) Publish(message MqttMessage) mqtt.Token { + return c.client.Publish( + c.Namespace+"/"+message.topic, + message.qos, + message.retain, + message.payload, + ) +} + +func (c *HassClient) Subscribe(topic string, handler MessageHandler) { + + var passHandler = func(client mqtt.Client, message mqtt.Message) { + handler(message) + } + + c.client.Subscribe( + c.Namespace+"/"+topic, + 2, + passHandler, + ) +} + +func (c *HassClient) Disconnect() { + c.client.Disconnect(500) +} + +func (c *HassClient) Connect(lwt MqttMessage) error { + connectionString := fmt.Sprintf( + "tcp://%v:%v", + os.Getenv("MQTT_HOST"), + os.Getenv("MQTT_PORT"), + ) + + opts := mqtt.NewClientOptions() + opts.AddBroker(connectionString) + opts.SetWill(c.Namespace+"/"+lwt.topic, lwt.payload, lwt.qos, lwt.retain) + opts.SetClientID(os.Getenv("MQTT_CLIENT_ID")) + opts.SetUsername(os.Getenv("MQTT_USERNAME")) + opts.SetPassword(os.Getenv("MQTT_PASSWORD")) + opts.SetKeepAlive(2 * time.Second) + opts.SetPingTimeout(1 * time.Second) + + mqttClient := mqtt.NewClient(opts) + + if token := mqttClient.Connect(); token.Wait() && token.Error() != nil { + return token.Error() + } + + c.client = mqttClient + + return nil +} diff --git a/pkg/hass/message_handler.go b/pkg/hass/message_handler.go new file mode 100644 index 0000000..1cd3119 --- /dev/null +++ b/pkg/hass/message_handler.go @@ -0,0 +1,5 @@ +package hass + +import mqtt "github.com/eclipse/paho.mqtt.golang" + +type MessageHandler func(mqtt.Message) diff --git a/pkg/hass/mqtt_message.go b/pkg/hass/mqtt_message.go new file mode 100644 index 0000000..68aeab1 --- /dev/null +++ b/pkg/hass/mqtt_message.go @@ -0,0 +1,25 @@ +package hass + +type MqttMessage struct { + topic string + payload string + retain bool + qos byte +} + +func MakeMqttMessage(topic string, payload string) MqttMessage { + return MqttMessage{ + topic: topic, + payload: payload, + } +} + +func (m MqttMessage) Retained() MqttMessage { + m.retain = true + return m +} + +func (m MqttMessage) Qos(qos byte) MqttMessage { + m.qos = qos + return m +} diff --git a/pkg/model/arduino_heartbeat.go b/pkg/model/arduino_heartbeat.go new file mode 100644 index 0000000..460ea06 --- /dev/null +++ b/pkg/model/arduino_heartbeat.go @@ -0,0 +1,20 @@ +package model + +import ( + "errors" + "time" +) + +type ArduinoHeartbeat struct { + Time time.Time +} + +func MakeArduinoHeartbeatFromString(line string) (ArduinoHeartbeat, error) { + if line != "HEARTBEAT" { + return ArduinoHeartbeat{}, errors.New("line was not an Arduino heartbeat") + } + + return ArduinoHeartbeat{ + Time: time.Now(), + }, nil +} diff --git a/pkg/model/hass_auto_discover_payload.go b/pkg/model/hass_auto_discover_payload.go new file mode 100644 index 0000000..c0b2723 --- /dev/null +++ b/pkg/model/hass_auto_discover_payload.go @@ -0,0 +1,5 @@ +package model + +type HassAutoDiscoverPayload interface { + WithGlobalTopicPrefix(prefix string, device *HassDevice, entity HassAutoDiscoverable) HassAutoDiscoverPayload +} diff --git a/pkg/model/hass_auto_discoverable.go b/pkg/model/hass_auto_discoverable.go new file mode 100644 index 0000000..7530d68 --- /dev/null +++ b/pkg/model/hass_auto_discoverable.go @@ -0,0 +1,7 @@ +package model + +type HassAutoDiscoverable interface { + MqttTopic(device *HassDevice) string + OverriddenMqttStateTopic(device *HassDevice) string + AutoDiscoveryPayload(device *HassDevice) HassAutoDiscoverPayload +} diff --git a/pkg/model/hass_device.go b/pkg/model/hass_device.go new file mode 100644 index 0000000..ab02da4 --- /dev/null +++ b/pkg/model/hass_device.go @@ -0,0 +1,31 @@ +package model + +type HassDevice struct { + Identifier string `json:"identifiers"` + Name string `json:"name"` + Model string `json:"model"` + Manufacturer string `json:"manufacturer"` + Namespace string `json:"-"` + EntityPrefix string `json:"-"` + AvailabilityTopic string `json:"-"` + PayloadAvailable string `json:"-"` + PayloadNotAvailable string `json:"-"` +} + +func NewHassDevice() *HassDevice { + return &HassDevice{ + Identifier: "vegetable-soaker", + Name: "Vegetable Soaker", + Model: "VegSoak 3000", + Manufacturer: "Josh Bonfield", + Namespace: "vegetable-soaker", + EntityPrefix: "vegetable-soaker-", + AvailabilityTopic: "availability", + PayloadAvailable: "online", + PayloadNotAvailable: "offline", + } +} + +func (d HassDevice) GetFqAvailabilityTopic() string { + return d.Namespace + "/availability" +} diff --git a/pkg/model/moisture_level.go b/pkg/model/moisture_level.go new file mode 100644 index 0000000..04dc674 --- /dev/null +++ b/pkg/model/moisture_level.go @@ -0,0 +1,11 @@ +package model + +type MoistureLevel struct { + Percentage uint `json:"percentage"` +} + +func MakeMoistureLevel(percentage uint) MoistureLevel { + return MoistureLevel{ + Percentage: percentage, + } +} diff --git a/pkg/model/moisture_reading.go b/pkg/model/moisture_reading.go new file mode 100644 index 0000000..250ab4b --- /dev/null +++ b/pkg/model/moisture_reading.go @@ -0,0 +1,47 @@ +package model + +import ( + "errors" + "strconv" + "strings" + "time" +) + +type MoistureReading struct { + Time time.Time `json:"recorded_at"` + Raw uint + MoistureLevel MoistureLevel `json:"percentage"` +} + +func (r *MoistureReading) CalculateMoistureLevelForSensor(sensor *MoistureSensor) { + r.MoistureLevel = MakeMoistureLevel( + sensor.mapRawReadingToPercentage(r.Raw), + ) +} + +// Returns the reading, the sensor ID and an error +func MakeMoistureReadingFromString(line string) (MoistureReading, uint, error) { + // MS:1:700:44 # MS:ID:RAW:PERCENTAGE + parts := strings.Split(line, ":") + + if parts[0] != "MS" { + return MoistureReading{}, 0, errors.New("line was not a moisture reading") + } + + sensorId, err := strconv.Atoi(parts[1]) + + if err != nil { + return MoistureReading{}, 0, err + } + + rawValue, err := strconv.Atoi(parts[2]) + + if err != nil { + return MoistureReading{}, 0, err + } + + return MoistureReading{ + Time: time.Now(), + Raw: uint(rawValue), + }, uint(sensorId), nil +} diff --git a/pkg/model/moisture_sensor.go b/pkg/model/moisture_sensor.go new file mode 100644 index 0000000..e6279d1 --- /dev/null +++ b/pkg/model/moisture_sensor.go @@ -0,0 +1,59 @@ +package model + +import ( + "strconv" + + "github.com/mewejo/go-watering/pkg/number" +) + +type MoistureSensor struct { + Id uint + Name string + DryThreshold uint `json:"dry_threshold"` + WetThreshold uint `json:"wet_threshold"` +} + +func (ms MoistureSensor) MqttTopic(device *HassDevice) string { + return "sensor/" + device.Namespace + "/sensor-" + ms.IdAsString() +} + +func (ms MoistureSensor) OverriddenMqttStateTopic(device *HassDevice) string { + return "" +} + +func (ms MoistureSensor) MqttStateTopic(device *HassDevice) string { + return ms.MqttTopic(device) + "/" + makeMoistureSensorHassConfiguration(ms, device).StateTopic +} + +func (ms MoistureSensor) AutoDiscoveryPayload(device *HassDevice) HassAutoDiscoverPayload { + return makeMoistureSensorHassConfiguration(ms, device) +} + +func (ms MoistureSensor) IdAsString() string { + return strconv.FormatUint(uint64(ms.Id), 10) +} + +func (ms MoistureSensor) mapRawReadingToPercentage(raw uint) uint { + value := number.ChangeRange( + float64(raw), + float64(ms.DryThreshold), + float64(ms.WetThreshold), + 0, + 100, + ) + + if value < 0 { + value = 0 + } + + return uint(value) +} + +func NewMoistureSensor(id uint, name string) *MoistureSensor { + return &MoistureSensor{ + Id: id, + Name: name, + DryThreshold: 500, + WetThreshold: 240, + } +} diff --git a/pkg/model/moisture_sensor_hass_configuration.go b/pkg/model/moisture_sensor_hass_configuration.go new file mode 100644 index 0000000..8ea90d3 --- /dev/null +++ b/pkg/model/moisture_sensor_hass_configuration.go @@ -0,0 +1,63 @@ +package model + +type moistureSensorHassConfiguration struct { + Name string `json:"name"` + DeviceClass string `json:"device_class"` + ObjectId string `json:"object_id"` + UniqueId string `json:"unique_id"` + StateTopic string `json:"state_topic"` + StateValueTemplate string `json:"value_template"` + AvailabilityTopic string `json:"availability_topic"` + UnitOfMeasurement string `json:"unit_of_measurement"` + HassDevice *HassDevice `json:"device"` + PayloadAvailable string `json:"payload_available"` + PayloadNotAvailable string `json:"payload_not_available"` + MqttRetain bool `json:"retain"` +} + +func (c moistureSensorHassConfiguration) WithGlobalTopicPrefix(prefix string, device *HassDevice, entity HassAutoDiscoverable) HassAutoDiscoverPayload { + c.AvailabilityTopic = prefix + "/" + c.HassDevice.GetFqAvailabilityTopic() + c.StateTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.StateTopic + + stateOverride := entity.OverriddenMqttStateTopic(device) + + if stateOverride != "" { + c.StateTopic = prefix + "/" + stateOverride + } + + return c +} + +func makeMoistureSensorForZoneAverageMoistureSensor(averageMoistureSensor ZoneAverageMoistureSensor, device *HassDevice) moistureSensorHassConfiguration { + c := makeMoistureSensorConfiugurationForDevice(device) + c.Name = averageMoistureSensor.Name + c.ObjectId = device.EntityPrefix + "zone-average-sensor-" + averageMoistureSensor.Id + c.UniqueId = c.ObjectId + c.StateValueTemplate = "{{ value_json.average_moisture.percentage }}" + + return c +} + +func makeMoistureSensorConfiugurationForDevice(device *HassDevice) moistureSensorHassConfiguration { + c := moistureSensorHassConfiguration{} + c.StateTopic = "state" + c.AvailabilityTopic = device.GetFqAvailabilityTopic() + c.DeviceClass = "humidity" + c.UnitOfMeasurement = "%" + c.PayloadAvailable = device.PayloadAvailable + c.PayloadNotAvailable = device.PayloadNotAvailable + c.HassDevice = device + c.MqttRetain = false + + return c +} + +func makeMoistureSensorHassConfiguration(sensor MoistureSensor, device *HassDevice) moistureSensorHassConfiguration { + c := makeMoistureSensorConfiugurationForDevice(device) + c.Name = sensor.Name + c.ObjectId = device.EntityPrefix + "sensor-" + sensor.IdAsString() + c.UniqueId = c.ObjectId + c.StateValueTemplate = "{{ value_json.moisture_level.percentage }}" + + return c +} diff --git a/pkg/model/moisture_sensor_hass_state.go b/pkg/model/moisture_sensor_hass_state.go new file mode 100644 index 0000000..a814b54 --- /dev/null +++ b/pkg/model/moisture_sensor_hass_state.go @@ -0,0 +1,6 @@ +package model + +type MoistureSensorHassState struct { + Sensor *MoistureSensor `json:"sensor"` + MoistureLevel MoistureLevel `json:"moisture_level"` +} diff --git a/pkg/model/water_outlet.go b/pkg/model/water_outlet.go new file mode 100644 index 0000000..a4c423f --- /dev/null +++ b/pkg/model/water_outlet.go @@ -0,0 +1,79 @@ +package model + +import ( + "errors" + "strconv" + "strings" +) + +type WaterOutlet struct { + Id uint `json:"id"` + Name string `json:"name"` + TargetState bool `json:"target"` + ActualState bool `json:"actual"` + IndependentlyControlled bool `json:"independently_controlled"` +} + +func NewWaterOutlet(id uint, name string, independentlyControlled bool) *WaterOutlet { + return &WaterOutlet{ + Id: id, + Name: name, + IndependentlyControlled: independentlyControlled, + } +} + +func (wo WaterOutlet) IdAsString() string { + return strconv.FormatUint(uint64(wo.Id), 10) +} + +func (wo WaterOutlet) MqttTopic(device *HassDevice) string { + return "switch/" + device.Namespace + "/outlet-" + wo.IdAsString() +} + +func (wo WaterOutlet) OverriddenMqttStateTopic(device *HassDevice) string { + return "" +} + +func (wo WaterOutlet) MqttStateTopic(device *HassDevice) string { + return wo.MqttTopic(device) + "/" + makeWaterOutletHassConfiguration(wo, device).StateTopic +} + +func (wo WaterOutlet) MqttCommandTopic(device *HassDevice) string { + return wo.MqttTopic(device) + "/" + makeWaterOutletHassConfiguration(wo, device).CommandTopic +} + +func (wo WaterOutlet) AutoDiscoveryPayload(device *HassDevice) HassAutoDiscoverPayload { + return makeWaterOutletHassConfiguration(wo, device) +} + +func DecodeWaterOutletStateFromString(line string) (uint, bool, bool, error) { + // WO:1:0:0 # WO:ID:REAL_STATE:SET_STATE (1 = on, 0 = off) + parts := strings.Split(line, ":") + + if parts[0] != "WO" { + return 0, false, false, errors.New("line was not a water outlet state") + } + + outletId, err := strconv.Atoi(parts[1]) + + if err != nil { + return 0, false, false, err + } + + actualState, err := strconv.Atoi(parts[2]) + + if err != nil { + return 0, false, false, err + } + + targetState, err := strconv.Atoi(parts[3]) + + if err != nil { + return 0, false, false, err + } + + return uint(outletId), + actualState == 1, + targetState == 1, + nil +} diff --git a/pkg/model/water_outlet_hass_configuration.go b/pkg/model/water_outlet_hass_configuration.go new file mode 100644 index 0000000..978de4d --- /dev/null +++ b/pkg/model/water_outlet_hass_configuration.go @@ -0,0 +1,54 @@ +package model + +import "github.com/mewejo/go-watering/pkg/constants" + +type waterOutletHassConfiguration struct { + Name string `json:"name"` + DeviceClass string `json:"device_class"` + ObjectId string `json:"object_id"` + UniqueId string `json:"unique_id"` + StateTopic string `json:"state_topic"` + StateValueTemplate string `json:"value_template"` + AvailabilityTopic string `json:"availability_topic"` + HassDevice *HassDevice `json:"device"` + PayloadAvailable string `json:"payload_available"` + PayloadNotAvailable string `json:"payload_not_available"` + CommandTopic string `json:"command_topic"` + StateOn string `json:"payload_on"` + StateOff string `json:"payload_off"` + MqttRetain bool `json:"retain"` +} + +func (c waterOutletHassConfiguration) WithGlobalTopicPrefix(prefix string, device *HassDevice, entity HassAutoDiscoverable) HassAutoDiscoverPayload { + c.AvailabilityTopic = prefix + "/" + c.HassDevice.GetFqAvailabilityTopic() + c.CommandTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.CommandTopic + c.StateTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.StateTopic + + stateOverride := entity.OverriddenMqttStateTopic(device) + + if stateOverride != "" { + c.StateTopic = prefix + "/" + stateOverride + } + + return c +} + +func makeWaterOutletHassConfiguration(outlet WaterOutlet, device *HassDevice) waterOutletHassConfiguration { + c := waterOutletHassConfiguration{} + c.Name = outlet.Name + c.StateOn = constants.HASS_STATE_ON + c.StateOff = constants.HASS_STATE_OFF + c.ObjectId = device.EntityPrefix + "outlet-" + outlet.IdAsString() + c.UniqueId = c.ObjectId + c.StateTopic = "state" + c.AvailabilityTopic = device.GetFqAvailabilityTopic() + c.DeviceClass = "switch" + c.StateValueTemplate = "{% if value_json.actual -%}" + c.StateOn + "{%- else -%}" + c.StateOff + "{%- endif %}" + c.PayloadAvailable = device.PayloadAvailable + c.PayloadNotAvailable = device.PayloadNotAvailable + c.HassDevice = device + c.CommandTopic = "command" + c.MqttRetain = true + + return c +} diff --git a/pkg/model/zone.go b/pkg/model/zone.go new file mode 100644 index 0000000..a608f5e --- /dev/null +++ b/pkg/model/zone.go @@ -0,0 +1,75 @@ +package model + +import ( + "time" +) + +type Zone struct { + Id string + Name string + Mode *ZoneMode + TargetMoisture MoistureLevel + MoistureSensors []*MoistureSensor + WaterOutlets []*WaterOutlet + Enabled bool + AverageMoistureSensor *ZoneAverageMoistureSensor + WaterOutletsState bool + WaterOutletsStateChangedAt time.Time +} + +func NewZone(id string, name string, sensors []*MoistureSensor, waterOutlets []*WaterOutlet) *Zone { + zone := &Zone{ + Id: id, + Name: name, + Mode: GetDefaultZoneMode(), + MoistureSensors: sensors, + WaterOutlets: waterOutlets, + Enabled: false, + TargetMoisture: MakeMoistureLevel(0), + WaterOutletsState: false, + WaterOutletsStateChangedAt: time.Now(), + } + + zone.AverageMoistureSensor = newZoneAverageMoistureSensor(zone) + + return zone +} + +func (zone *Zone) SetWaterOutletsState(state bool) { + + changing := zone.WaterOutletsState != state + + zone.WaterOutletsState = state + + if changing { + zone.WaterOutletsStateChangedAt = time.Now() + } +} + +func (zone Zone) MqttTopic(device *HassDevice) string { + return "humidifier/" + device.Namespace + "/zone-" + zone.Id +} + +func (zone Zone) OverriddenMqttStateTopic(device *HassDevice) string { + return "" +} + +func (zone Zone) MqttCommandTopic(device *HassDevice) string { + return zone.MqttTopic(device) + "/" + makeZoneHassConfiguration(zone, device).CommandTopic +} + +func (zone Zone) MqttTargetMoistureCommandTopic(device *HassDevice) string { + return zone.MqttTopic(device) + "/" + makeZoneHassConfiguration(zone, device).TargetMoistureCommandTopic +} + +func (zone Zone) MqttModeCommandTopic(device *HassDevice) string { + return zone.MqttTopic(device) + "/" + makeZoneHassConfiguration(zone, device).ModeCommandTopic +} + +func (zone Zone) MqttStateTopic(device *HassDevice) string { + return zone.MqttTopic(device) + "/" + makeZoneHassConfiguration(zone, device).StateTopic +} + +func (zone Zone) AutoDiscoveryPayload(device *HassDevice) HassAutoDiscoverPayload { + return makeZoneHassConfiguration(zone, device) +} diff --git a/pkg/model/zone_average_moisture_sensor.go b/pkg/model/zone_average_moisture_sensor.go new file mode 100644 index 0000000..64c17c7 --- /dev/null +++ b/pkg/model/zone_average_moisture_sensor.go @@ -0,0 +1,27 @@ +package model + +type ZoneAverageMoistureSensor struct { + Id string + Name string + Zone *Zone +} + +func newZoneAverageMoistureSensor(zone *Zone) *ZoneAverageMoistureSensor { + return &ZoneAverageMoistureSensor{ + Id: "zone-" + zone.Id + "-average-moisture", + Name: zone.Name + " Average Moisture", + Zone: zone, + } +} + +func (sensor ZoneAverageMoistureSensor) OverriddenMqttStateTopic(device *HassDevice) string { + return sensor.Zone.MqttStateTopic(device) +} + +func (sensor ZoneAverageMoistureSensor) MqttTopic(device *HassDevice) string { + return "sensor/" + device.Namespace + "/zone-" + sensor.Zone.Id + "-average-moisture-sensor" +} + +func (sensor ZoneAverageMoistureSensor) AutoDiscoveryPayload(device *HassDevice) HassAutoDiscoverPayload { + return makeMoistureSensorForZoneAverageMoistureSensor(sensor, device) +} diff --git a/pkg/model/zone_hass_configuration.go b/pkg/model/zone_hass_configuration.go new file mode 100644 index 0000000..b26855e --- /dev/null +++ b/pkg/model/zone_hass_configuration.go @@ -0,0 +1,73 @@ +package model + +import "github.com/mewejo/go-watering/pkg/constants" + +type zoneHassConfiguration struct { + Name string `json:"name"` + ObjectId string `json:"object_id"` + UniqueId string `json:"unique_id"` + StateTopic string `json:"state_topic"` + StateValueTemplate string `json:"state_value_template"` + AvailabilityTopic string `json:"availability_topic"` + HassDevice *HassDevice `json:"device"` + PayloadAvailable string `json:"payload_available"` + PayloadNotAvailable string `json:"payload_not_available"` + CommandTopic string `json:"command_topic"` + StateOn string `json:"payload_on"` + StateOff string `json:"payload_off"` + TargetMoistureCommandTopic string `json:"target_humidity_command_topic"` + TargetMoistureStateTopic string `json:"target_humidity_state_topic"` + TargetMoistureStateValueTemplate string `json:"target_humidity_state_template"` + ModeStateTemplate string `json:"mode_state_template"` + Modes []string `json:"modes"` + ModeCommandTopic string `json:"mode_command_topic"` + ModeStateTopic string `json:"mode_state_topic"` + MqttRetain bool `json:"retain"` +} + +func (c zoneHassConfiguration) WithGlobalTopicPrefix(prefix string, device *HassDevice, entity HassAutoDiscoverable) HassAutoDiscoverPayload { + c.AvailabilityTopic = prefix + "/" + c.HassDevice.GetFqAvailabilityTopic() + c.CommandTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.CommandTopic + c.ModeCommandTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.ModeCommandTopic + c.ModeStateTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.ModeStateTopic + c.StateTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.StateTopic + c.TargetMoistureStateTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.TargetMoistureStateTopic + c.TargetMoistureCommandTopic = prefix + "/" + entity.MqttTopic(device) + "/" + c.TargetMoistureCommandTopic + + stateOverride := entity.OverriddenMqttStateTopic(device) + + if stateOverride != "" { + c.StateTopic = prefix + "/" + stateOverride + } + + return c +} + +func makeZoneHassConfiguration(zone Zone, device *HassDevice) zoneHassConfiguration { + c := zoneHassConfiguration{} + c.Name = zone.Name + c.StateOn = constants.HASS_STATE_ON + c.StateOff = constants.HASS_STATE_OFF + c.ObjectId = device.EntityPrefix + "zone-" + zone.Id + c.UniqueId = c.ObjectId + c.StateTopic = "state" + c.TargetMoistureStateTopic = "state" + c.TargetMoistureCommandTopic = "target_moisture" + c.AvailabilityTopic = device.GetFqAvailabilityTopic() + c.ModeStateTemplate = "{{ value_json.mode.key }}" + c.TargetMoistureStateValueTemplate = "{{ value_json.target_moisture.percentage }}" + c.StateValueTemplate = "{% if value_json.enabled -%}" + c.StateOn + "{%- else -%}" + c.StateOff + "{%- endif %}" + c.PayloadAvailable = device.PayloadAvailable + c.PayloadNotAvailable = device.PayloadNotAvailable + c.HassDevice = device + c.CommandTopic = "command" + c.ModeCommandTopic = "mode_command" + c.ModeStateTopic = "state" + c.MqttRetain = true + + for _, mode := range zoneModes { + c.Modes = append(c.Modes, mode.Key) + } + + return c +} diff --git a/pkg/model/zone_hass_state.go b/pkg/model/zone_hass_state.go new file mode 100644 index 0000000..fd895a0 --- /dev/null +++ b/pkg/model/zone_hass_state.go @@ -0,0 +1,19 @@ +package model + +type ZoneHassState struct { + Mode *ZoneMode `json:"mode"` + AverageMoisture MoistureLevel `json:"average_moisture"` + TargetMoisture MoistureLevel `json:"target_moisture"` + Enabled bool `json:"enabled"` + WaterOutletsState bool `json:"water_outlets_open"` +} + +func MakeZoneHassState(zone *Zone, averageMoisture MoistureLevel) ZoneHassState { + return ZoneHassState{ + Mode: zone.Mode, + AverageMoisture: averageMoisture, + Enabled: zone.Enabled, + TargetMoisture: zone.TargetMoisture, + WaterOutletsState: zone.WaterOutletsState, + } +} diff --git a/pkg/model/zone_mode.go b/pkg/model/zone_mode.go new file mode 100644 index 0000000..edcea7d --- /dev/null +++ b/pkg/model/zone_mode.go @@ -0,0 +1,56 @@ +package model + +import ( + "errors" + "log" + "time" +) + +type ZoneMode struct { + Key string `json:"key"` + CutOffDuration time.Duration `json:"-"` +} + +const normalMode = "normal" + +var zoneModes = []*ZoneMode{ + { + Key: normalMode, + CutOffDuration: time.Minute * 30, + }, + + { + Key: "15 Minute Boost", + CutOffDuration: time.Minute * 15, + }, + + { + Key: "30 Minute Boost", + CutOffDuration: time.Minute * 30, + }, + + { + Key: "1 hour Boost", + CutOffDuration: time.Hour, + }, +} + +func GetDefaultZoneMode() *ZoneMode { + mode, err := GetZoneModeFromKey(normalMode) + + if err != nil { + log.Fatal(err) + } + + return mode +} + +func GetZoneModeFromKey(key string) (*ZoneMode, error) { + for _, mode := range zoneModes { + if mode.Key == key { + return mode, nil + } + } + + return &ZoneMode{}, errors.New("invalid zone mode key") +} diff --git a/pkg/number/change_range.go b/pkg/number/change_range.go new file mode 100644 index 0000000..3a6c4f3 --- /dev/null +++ b/pkg/number/change_range.go @@ -0,0 +1,6 @@ +package number + +// Source: https://www.arduino.cc/reference/en/language/functions/math/map/ +func ChangeRange(input float64, inputMin float64, inputMax float64, outputMin float64, outputMax float64) float64 { + return (input-inputMin)*(outputMax-outputMin)/(inputMax-inputMin) + outputMin +} diff --git a/pkg/persistence/moisture_reading_store.go b/pkg/persistence/moisture_reading_store.go new file mode 100644 index 0000000..773c780 --- /dev/null +++ b/pkg/persistence/moisture_reading_store.go @@ -0,0 +1,114 @@ +package persistence + +import ( + "errors" + "time" + + "github.com/mewejo/go-watering/pkg/model" +) + +type moistureReadingStore struct { + sensorId uint + readings []model.MoistureReading +} + +func (s *moistureReadingStore) getAverageSince(since time.Duration) (model.MoistureLevel, error) { + readings := []model.MoistureLevel{} + + cutOffTime := time.Now().Add(-since) + + for _, reading := range s.readings { + if reading.Time.Before(cutOffTime) { + continue + } + + readings = append(readings, reading.MoistureLevel) + } + + if len(readings) < 1 { + return model.MoistureLevel{}, errors.New("no readings to calculate average from") + } + + var totalPercentage uint + + for _, reading := range readings { + totalPercentage += reading.Percentage + } + + return model.MakeMoistureLevel( + uint(totalPercentage / uint(len(readings))), + ), nil +} + +func (s *moistureReadingStore) recordReading(r model.MoistureReading) { + s.readings = append(s.readings, r) + limitReadings(&s.readings, 1000) +} + +func (s *moistureReadingStore) getLatest() (*model.MoistureReading, error) { + if len(s.readings) < 1 { + return &model.MoistureReading{}, errors.New("no readings available") + } + + return &s.readings[len(s.readings)-1], nil +} + +func limitReadings(s *[]model.MoistureReading, length int) { + if len(*s) <= length { + return + } + + *s = (*s)[len(*s)-length:] +} + +var moistureReadingStores []*moistureReadingStore + +func GetLatestReadingForMoistureSensorId(sensorId uint) (*model.MoistureReading, error) { + return getOrMakeStore(sensorId).getLatest() +} + +func GetAverageReadingForSensorIdSince(sensorId uint, since time.Duration) (model.MoistureLevel, error) { + return getOrMakeStore(sensorId).getAverageSince(since) +} + +func GetAverageReadingForSensorsSince(sensors []*model.MoistureSensor, since time.Duration) (model.MoistureLevel, error) { + if len(sensors) < 1 { + return model.MoistureLevel{}, errors.New("no sensors provided") + } + + var totalPercentage uint + + for _, sensor := range sensors { + level, err := getOrMakeStore(sensor.Id).getAverageSince(since) + + if err != nil { + return model.MoistureLevel{}, err + } + + totalPercentage += level.Percentage + } + + return model.MakeMoistureLevel( + uint(totalPercentage / uint(len(sensors))), + ), nil +} + +func RecordMoistureReading(sensorId uint, reading model.MoistureReading) { + getOrMakeStore(sensorId).recordReading(reading) +} + +func getOrMakeStore(sensorId uint) *moistureReadingStore { + for _, store := range moistureReadingStores { + if store.sensorId == sensorId { + return store + } + } + + store := moistureReadingStore{ + sensorId: sensorId, + } + + moistureReadingStores = append(moistureReadingStores, &store) + + return &store +} diff --git a/pkg/slice/slices.go b/pkg/slice/slices.go new file mode 100644 index 0000000..cdadba1 --- /dev/null +++ b/pkg/slice/slices.go @@ -0,0 +1,14 @@ +package slice + +func ReverseSliceInPlace[S ~[]E, E any](s S) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} + +func ReverseSlice[S ~[]E, E any](s S) S { + new := make([]E, len(s)) + copy(new, s) + ReverseSliceInPlace(new) + return new +} diff --git a/world/moisture_level.go b/world/moisture_level.go deleted file mode 100644 index 9db9a64..0000000 --- a/world/moisture_level.go +++ /dev/null @@ -1,34 +0,0 @@ -package world - -// Percentage of hysteresis to use (decimal, 0.05 = 5%) -const hysteresis = 0.05 - -type MoistureLevel struct { - Percentage uint `json:"percentage"` -} - -func percentageToMoistureLevel(level float64) MoistureLevel { - if level > 100 { - level = 100 - } - - if level < 0 { - level = 0 - } - - return MoistureLevel{ - Percentage: uint(level), - } -} - -func (ml MoistureLevel) HysteresisOnLevel() MoistureLevel { - return percentageToMoistureLevel( - float64(ml.Percentage) * (1.0 - hysteresis), - ) -} - -func (ml MoistureLevel) HysteresisOffLevel() MoistureLevel { - return percentageToMoistureLevel( - float64(ml.Percentage) * (1.0 + hysteresis), - ) -}