diff --git a/cmd/fermentord/main.go b/cmd/fermentord/main.go index 0dfffef..52a1c0d 100644 --- a/cmd/fermentord/main.go +++ b/cmd/fermentord/main.go @@ -13,6 +13,7 @@ import ( "git.joco.dk/sng/fermentord/internal/controllers" "git.joco.dk/sng/fermentord/internal/dwingest" "git.joco.dk/sng/fermentord/internal/hw" + "git.joco.dk/sng/fermentord/internal/lcd" "git.joco.dk/sng/fermentord/internal/metrics" "git.joco.dk/sng/fermentord/pkg/daemon" "git.joco.dk/sng/fermentord/pkg/temperature" @@ -29,6 +30,14 @@ func mainLoop(ctx context.Context, wg *sync.WaitGroup, js nats.JetStream, config defer hub.Flush(10 * time.Second) defer wg.Done() + // Display + display, err := lcd.NewLCD() + if err != nil { + hub.CaptureException(err) + log.Fatal(err) + } + defer display.Close() + // Controller ctrl := controllers.NewChamberController("Chamber 1", *config) @@ -36,11 +45,17 @@ func mainLoop(ctx context.Context, wg *sync.WaitGroup, js nats.JetStream, config ingest := dwingest.NewDWIngest() // Configuration reload + loadConfiguration := func() { + temperature.ConfigUpdate <- *config + ctrl.ConfigUpdates <- *config + display.SetSetpointTemp(config.FermentationTemperature) + } + viper.OnConfigChange(func(in fsnotify.Event) { - controllers.ReloadConfiguration(config, ctrl) + loadConfiguration() }) + loadConfiguration() viper.WatchConfig() - controllers.ReloadConfiguration(config, ctrl) gpio, err := hw.NewGpio() if err != nil { @@ -49,7 +64,8 @@ func mainLoop(ctx context.Context, wg *sync.WaitGroup, js nats.JetStream, config } defer gpio.Close() - wg.Add(4) + wg.Add(5) + go display.Run(ctx, wg) go ctrl.Run(ctx, wg) go ingest.Run(ctx, wg, js, config) go temperature.PollSensors(ctx, wg, 1*time.Second, config.Sensors.Weight) @@ -57,13 +73,15 @@ func mainLoop(ctx context.Context, wg *sync.WaitGroup, js nats.JetStream, config for { select { - case reading := <-temperature.C: + case reading := <-temperature.Reading: ctrl.SetTemperature(reading) ingest.AddReading(reading) + display.SetTemperature(reading) case state := <-ctrl.C: gpioSetState(state, gpio) ingest.AddState(state) + display.SetState(state) case <-ctx.Done(): return diff --git a/go.mod b/go.mod index 19f3f3f..1d57cde 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.18 require ( github.com/JuulLabs-OSS/ble v0.0.0-20200716215611-d4fcc9d598bb + github.com/d2r2/go-hd44780 v0.0.0-20181002113701-74cc28c83a3e + github.com/d2r2/go-i2c v0.0.0-20191123181816-73a8a799d6bc github.com/fsnotify/fsnotify v1.5.4 github.com/getsentry/sentry-go v0.13.0 github.com/nats-io/nats.go v1.15.0 @@ -17,6 +19,8 @@ require ( github.com/JuulLabs-OSS/cbgo v0.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/d2r2/go-logger v0.0.0-20210606094344-60e9d1233e22 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect @@ -30,6 +34,7 @@ require ( github.com/nats-io/nats-server/v2 v2.7.1 // indirect github.com/nats-io/nkeys v0.3.0 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect diff --git a/go.sum b/go.sum index 3716b7c..691841c 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,12 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/d2r2/go-hd44780 v0.0.0-20181002113701-74cc28c83a3e h1:3gLJWdofXjBoecDb9e+giWp77saiF6r2Mtu+edWCksY= +github.com/d2r2/go-hd44780 v0.0.0-20181002113701-74cc28c83a3e/go.mod h1:IruYZr0O1UbQs3rV5N2WPM8CpaT5rRgvPPzksu1+N6o= +github.com/d2r2/go-i2c v0.0.0-20191123181816-73a8a799d6bc h1:HLRSIWzUGMLCq4ldt0W1GLs3nnAxa5EGoP+9qHgh6j0= +github.com/d2r2/go-i2c v0.0.0-20191123181816-73a8a799d6bc/go.mod h1:AwxDPnsgIpy47jbGXZHA9Rv7pDkOJvQbezPuK1Y+nNk= +github.com/d2r2/go-logger v0.0.0-20210606094344-60e9d1233e22 h1:nO+SY4KOMsF/LsZ5EtbSKhiT3M6sv/igo2PEru/xEHI= +github.com/d2r2/go-logger v0.0.0-20210606094344-60e9d1233e22/go.mod h1:eSx+YfcVy5vCjRZBNIhpIpfCGFMQ6XSOSQkDk7+VCpg= 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= @@ -211,6 +217,8 @@ github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU= diff --git a/internal/controllers/chamber.go b/internal/controllers/chamber.go index ee78ad5..f87e802 100644 --- a/internal/controllers/chamber.go +++ b/internal/controllers/chamber.go @@ -2,7 +2,6 @@ package controllers import ( "context" - "fmt" "log" "sync" "time" @@ -74,8 +73,10 @@ func (p *ChamberController) Run(ctx context.Context, wg *sync.WaitGroup) { state := p.computeChamberState() p.setChamberState(state) - case temp := <-p.chTemp: - p.updateTemperature(temp) + case t := <-p.chTemp: + p.ambientTemperature = t.Ambient + p.chamberTemperature = t.Chamber + p.wortTemperature = t.Wort case c := <-p.ConfigUpdates: p.config = c @@ -95,23 +96,6 @@ func (p *ChamberController) SetTemperature(t temperature.TemperatureReading) { p.chTemp <- t } -func (p *ChamberController) updateTemperature(t temperature.TemperatureReading) { - switch t.Sensor { - case p.config.Sensors.Ambient: - p.ambientTemperature = t.Degrees - - case p.config.Sensors.Chamber: - p.chamberTemperature = t.Degrees - - case p.config.Sensors.Wort: - p.wortTemperature = t.Degrees - - default: - log.Printf("Unknown sensor: sensor=%v temp=%v", t.Sensor, t.Degrees) - p.hub.CaptureMessage(fmt.Sprintf("Unknown sensor: sensor=%v temp=%v", t.Sensor, t.Degrees)) - } -} - func (p *ChamberController) setChamberState(state ChamberState) { if state == p.chamberState { return diff --git a/internal/controllers/config.go b/internal/controllers/config.go deleted file mode 100644 index dd53d47..0000000 --- a/internal/controllers/config.go +++ /dev/null @@ -1,20 +0,0 @@ -package controllers - -import ( - "log" - - "git.joco.dk/sng/fermentord/internal/configuration" - "git.joco.dk/sng/fermentord/pkg/temperature" -) - -func ReloadConfiguration(config *configuration.Configuration, ctrl *ChamberController) { - log.Printf("Reloading configuration") - - temperature.ConfigUpdates <- []string{ - config.Sensors.Ambient, - config.Sensors.Chamber, - config.Sensors.Wort, - } - - ctrl.ConfigUpdates <- *config -} diff --git a/internal/lcd/hd44780.go b/internal/lcd/hd44780.go new file mode 100644 index 0000000..413d028 --- /dev/null +++ b/internal/lcd/hd44780.go @@ -0,0 +1,146 @@ +package lcd + +import ( + "context" + "fmt" + "sync" + + "git.joco.dk/sng/fermentord/internal/controllers" + "git.joco.dk/sng/fermentord/pkg/temperature" + device "github.com/d2r2/go-hd44780" + "github.com/d2r2/go-i2c" +) + +type LCD struct { + ambient, chamber, setpoint, wort float64 + state string + + bus *i2c.I2C + lcd *device.Lcd + l1, l2 string + chTemp chan temperature.TemperatureReading + chState chan controllers.ChamberState + chSetpoint chan float64 +} + +func NewLCD() (*LCD, error) { + var err error + + p := &LCD{ + chTemp: make(chan temperature.TemperatureReading, 10), + chState: make(chan controllers.ChamberState, 10), + chSetpoint: make(chan float64, 10), + } + + p.bus, err = i2c.NewI2C(0x27, 2) + if err != nil { + return nil, err + } + + p.lcd, err = device.NewLcd(p.bus, device.LCD_16x2) + if err != nil { + p.bus.Close() + return nil, err + } + + err = p.lcd.BacklightOn() + if err != nil { + p.bus.Close() + return nil, err + } + + err = p.lcd.ShowMessage("Fermentor", device.SHOW_LINE_1|device.SHOW_BLANK_PADDING) + if err != nil { + p.bus.Close() + return nil, err + } + + err = p.lcd.ShowMessage("Initializing", device.SHOW_LINE_2|device.SHOW_BLANK_PADDING) + if err != nil { + p.bus.Close() + return nil, err + } + + return p, nil + +} + +func (p *LCD) Close() error { + return p.bus.Close() +} + +func (p *LCD) SetTemperature(t temperature.TemperatureReading) { + p.chTemp <- t +} + +func (p *LCD) SetSetpointTemp(t float64) { + p.chSetpoint <- t +} + +func (p *LCD) SetState(state controllers.ChamberState) { + p.chState <- state +} + +func (p *LCD) Run(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + for { + select { + case t := <-p.chTemp: + p.ambient = t.Ambient + p.chamber = t.Chamber + p.wort = t.Wort + p.update() + + case state := <-p.chState: + switch state { + case controllers.ChamberStateIdle: + p.state = "Id" + + case controllers.ChamberStateHeating: + p.state = "He" + + case controllers.ChamberStateCooling: + p.state = "Co" + } + p.update() + + case t := <-p.chSetpoint: + p.setpoint = t + p.update() + + case <-ctx.Done(): + p.lcd.ShowMessage("Fermentor", device.SHOW_LINE_1|device.SHOW_BLANK_PADDING) + p.lcd.ShowMessage("Shutting down", device.SHOW_LINE_2|device.SHOW_BLANK_PADDING) + + close(p.chSetpoint) + close(p.chState) + close(p.chTemp) + + return + } + } +} + +func (p *LCD) update() error { + l1 := fmt.Sprintf("W:%4.1f C:%4.1f %s", p.wort, p.chamber, p.state) + l2 := fmt.Sprintf("S:%4.1f A:%4.1f", p.ambient, p.setpoint) + + if l1 != p.l1 { + if err := p.lcd.ShowMessage(l1, device.SHOW_LINE_1|device.SHOW_BLANK_PADDING); err != nil { + return err + } + + p.l1 = l1 + } + + if l2 != p.l2 { + if err := p.lcd.ShowMessage(l2, device.SHOW_LINE_2|device.SHOW_BLANK_PADDING); err != nil { + return err + } + + p.l2 = l2 + } + + return nil +} diff --git a/pkg/temperature/ds18b20.go b/pkg/temperature/ds18b20.go index 1110781..8fc7ee1 100644 --- a/pkg/temperature/ds18b20.go +++ b/pkg/temperature/ds18b20.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "git.joco.dk/sng/fermentord/internal/configuration" "git.joco.dk/sng/fermentord/internal/metrics" "github.com/getsentry/sentry-go" ) @@ -21,6 +22,8 @@ import ( type BulkReadBusState int const ( + NaN = -999 + BulkReadIdle BulkReadBusState = 0 BulkReadBusy BulkReadBusState = -1 BulkReadReady BulkReadBusState = 1 @@ -35,23 +38,26 @@ var ( ) var ( - // C receives the temperature readings - C chan TemperatureReading + // Reading receives the temperature readings + Reading chan TemperatureReading + ConfigUpdate chan configuration.Configuration - // ConfigUpdates receives the list of sensors to read - ConfigUpdates chan []string - sensors []string accErrTrigger int - accErrSensor map[string]int - filters map[string]*EWMAFilter + sensors []Sensor ) func init() { - sensors = make([]string, 0) - filters = make(map[string]*EWMAFilter) - C = make(chan TemperatureReading, 30) - ConfigUpdates = make(chan []string, 1) - accErrSensor = make(map[string]int) + Reading = make(chan TemperatureReading, 30) + ConfigUpdate = make(chan configuration.Configuration) + sensors = make([]Sensor, 0) +} + +func configure(config configuration.Configuration) { + sensors = []Sensor{ + NewSensor("Ambient", config.Sensors.Ambient, config.Sensors.Weight), + NewSensor("Chamber", config.Sensors.Chamber, config.Sensors.Weight), + NewSensor("Wort", config.Sensors.Wort, config.Sensors.Weight), + } } func PollSensors(ctx context.Context, wg *sync.WaitGroup, readingInterval time.Duration, filterWeight float64) { @@ -76,12 +82,6 @@ func PollSensors(ctx context.Context, wg *sync.WaitGroup, readingInterval time.D log.Fatal("Thermal bulk read failed 60 times in a row -- terminating") } - for _, sensor := range sensors { - if accErrSensor[sensor] > 60 { - log.Fatalf("Thermal sensor read of %v failed 60 times in a row -- terminating", sensor) - } - } - // Trigger a bulk read to start conversion on all sensors. if err := triggerBulkRead(); err != nil { accErrTrigger++ @@ -118,52 +118,57 @@ func PollSensors(ctx context.Context, wg *sync.WaitGroup, readingInterval time.D readSensors(hub) } - case c := <-ConfigUpdates: - sensors = c - - for k := range filters { - delete(filters, k) - } - for _, x := range sensors { - filters[x] = NewEWMAFilter(filterWeight) - } + case c := <-ConfigUpdate: + configure(c) case <-ctx.Done(): - close(C) + close(Reading) return } } } func readSensors(hub *sentry.Hub) { + r := TemperatureReading{ + Time: time.Now(), + Ambient: NaN, + Chamber: NaN, + Wort: NaN, + } + for _, sensor := range sensors { - t, err := read(sensor) + t, err := read(sensor.Path) if err != nil { - accErrSensor[sensor]++ + sensor.Fail() hub.CaptureException(err) log.Printf("Error reading temperature sensor %v: %v", sensor, err) - metrics.TemperatureSensorReadingStatus.WithLabelValues(sensor, "failed").Inc() + metrics.TemperatureSensorReadingStatus.WithLabelValues(sensor.Name, "failed").Inc() continue } - accErrSensor[sensor] = 0 - tw := filters[sensor].Update(float64(t) / 1000) + tw := sensor.Update(t) - r := TemperatureReading{ - Time: time.Now(), - Sensor: sensor, - Degrees: tw, + switch sensor.Name { + case "Ambient": + r.Ambient = tw + + case "Chamber": + r.Chamber = tw + + case "Wort": + r.Wort = tw } + // TODO Move metrics out metrics.TemperatureSensorReadingDegreesCelcius. - WithLabelValues(sensor). + WithLabelValues(sensor.Name). Set(tw) - C <- r - - metrics.TemperatureSensorReadingStatus.WithLabelValues(sensor, "ok").Inc() + metrics.TemperatureSensorReadingStatus.WithLabelValues(sensor.Name, "ok").Inc() } + + Reading <- r } func triggerBulkRead() error { diff --git a/pkg/temperature/filter.go b/pkg/temperature/filter.go index 4a48856..b67a14a 100644 --- a/pkg/temperature/filter.go +++ b/pkg/temperature/filter.go @@ -8,8 +8,8 @@ type EWMAFilter struct { isInitialized bool } -func NewEWMAFilter(weight float64) *EWMAFilter { - return &EWMAFilter{ +func NewEWMAFilter(weight float64) EWMAFilter { + return EWMAFilter{ weight: weight, } } diff --git a/pkg/temperature/sensor.go b/pkg/temperature/sensor.go new file mode 100644 index 0000000..eddefb5 --- /dev/null +++ b/pkg/temperature/sensor.go @@ -0,0 +1,35 @@ +package temperature + +import "log" + +type Sensor struct { + Name string + Path string + Filter EWMAFilter + accErrors int +} + +func NewSensor(name, path string, weight float64) Sensor { + return Sensor{ + Name: name, + Path: path, + Filter: NewEWMAFilter(weight), + } +} + +func (p *Sensor) Update(value int64) float64 { + p.accErrors = 0 + degrees := float64(value) / 1000 + + return p.Filter.Update(degrees) +} + +func (p *Sensor) Fail() error { + p.accErrors++ + + if p.accErrors > 60 { + log.Fatalf("Thermal sensor read of %v failed 60 times in a row -- terminating", p.Name) + } + + return nil +} diff --git a/pkg/temperature/temperature.go b/pkg/temperature/temperature.go index 13dc9d1..5abe698 100644 --- a/pkg/temperature/temperature.go +++ b/pkg/temperature/temperature.go @@ -3,7 +3,8 @@ package temperature import "time" type TemperatureReading struct { - Sensor string `json:"sensor"` Time time.Time `json:"time"` - Degrees float64 `json:"degrees"` + Ambient float64 `json:"ambient"` + Chamber float64 `json:"chamber"` + Wort float64 `json:"wort"` }