fermentord/internal/controllers/chamber.go

218 lines
5.6 KiB
Go

package controllers
import (
"context"
"fmt"
"log"
"sync"
"time"
"git.joco.dk/sng/fermentord/internal/configuration"
"git.joco.dk/sng/fermentord/pkg/temperature"
"github.com/getsentry/sentry-go"
)
type ChamberState int
const (
ChamberStateIdle ChamberState = iota
ChamberStateCooling
ChamberStateHeating
)
var ChamberStateMap = map[ChamberState]string{
ChamberStateIdle: "IDLE",
ChamberStateCooling: "COOLING",
ChamberStateHeating: "HEATING",
}
type ChamberController struct {
ConfigUpdates chan configuration.Configuration
config configuration.Configuration
// Current state.
chamberState ChamberState
lastChamberStateChange time.Time
lastCoolerStateChange time.Time
C chan ChamberState
isPaused bool
// Current temperature readings.
ambientTemperature float64
chamberTemperature float64
wortTemperature float64
lastTemperatureUpdate time.Time
chTemp chan temperature.TemperatureReading
chPause chan bool
pid *PIDController
hub *sentry.Hub
}
func NewChamberController(config configuration.Configuration) *ChamberController {
return &ChamberController{
C: make(chan ChamberState),
config: config,
pid: NewPIDController(config.PID.Kp, config.PID.Ki, config.PID.Kd),
chTemp: make(chan temperature.TemperatureReading),
chPause: make(chan bool, 1),
chamberState: ChamberStateIdle,
ConfigUpdates: make(chan configuration.Configuration, 1),
hub: sentry.CurrentHub().Clone(),
}
}
func (p *ChamberController) Run(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
defer p.hub.Flush(10 * time.Second)
ticker := time.NewTicker(1 * time.Second)
for {
select {
case <-ticker.C:
state := p.computeChamberState()
p.setChamberState(state)
case t := <-p.chTemp:
p.ambientTemperature = t.Ambient
p.chamberTemperature = t.Chamber
p.wortTemperature = t.Wort
p.lastTemperatureUpdate = time.Now()
case pause := <-p.chPause:
p.setPause(pause)
case c := <-p.ConfigUpdates:
p.config = c
log.Printf("Fermentation temperature set to %v", p.config.FermentationTemperature)
case <-ctx.Done():
ticker.Stop()
p.isPaused = false
return
}
}
}
func (p *ChamberController) SetTemperature(t temperature.TemperatureReading) {
p.chTemp <- t
}
// Pause is a thread-safe way to pause the controller.
func (p *ChamberController) Pause() {
p.chPause <- true
}
// Resume is a thread-safe way to resume the controller.
func (p *ChamberController) Resume() {
p.chPause <- false
}
func (p *ChamberController) setPause(pause bool) {
if pause {
p.setChamberState(ChamberStateIdle)
p.isPaused = true
} else {
p.isPaused = false
state := p.computeChamberState()
p.setChamberState(state)
}
}
func (p *ChamberController) setChamberState(state ChamberState) {
if state == p.chamberState {
return
}
if p.isPaused {
return
}
//log.Printf("State changed from %v to %v", p.chamberState, state)
if p.chamberState == ChamberStateCooling || state == ChamberStateCooling {
p.lastCoolerStateChange = time.Now()
}
p.chamberState = state
p.lastChamberStateChange = time.Now()
p.C <- state
}
func (p *ChamberController) computeChamberState() ChamberState {
//offset := p.pid.Compute(p.chamberTemperature, p.config.FermentationTemperature)
//chamberTargetTemp := p.config.FermentationTemperature + offset
// Stay in idle mode until first temperature update is received
if p.lastTemperatureUpdate.IsZero() {
return ChamberStateIdle
}
runtimeSecs := time.Since(p.lastChamberStateChange).Seconds()
if p.chamberState == ChamberStateCooling {
// Ensure compressor min. runtime
if runtimeSecs < p.config.Limits.MinCoolerRuntimeSecs {
return p.chamberState
}
// Limit compressor runtime
if runtimeSecs > p.config.Limits.MaxCoolerRuntimeSecs {
return ChamberStateIdle
}
// Limit chamber min. temp.
if p.chamberTemperature < p.config.Limits.MinChamberTemperature {
return ChamberStateIdle
}
// Limit chamber max. temp.
if p.chamberTemperature > p.config.Limits.MaxChamberTemperature {
return ChamberStateIdle
}
}
var next ChamberState
heater := p.config.HeaterEnabled && p.chamberTemperature < p.config.FermentationTemperature-p.config.DeltaTemperatureHeat //&&
//p.chamberTemperature < chamberTargetTemp-p.config.DeltaTemperatureHeat
cooler := p.config.CoolerEnabled && p.chamberTemperature > p.config.FermentationTemperature+p.config.DeltaTemperatureCool //&&
//p.chamberTemperature > chamberTargetTemp+p.config.DeltaTemperatureCool
if cooler && heater {
// This should NOT happen!
err := fmt.Errorf("heater and cooler activated at same time")
p.hub.CaptureException(err)
log.Print(err)
next = ChamberStateIdle
} else if !cooler && !heater {
next = ChamberStateIdle
} else if cooler {
next = ChamberStateCooling
} else if heater {
next = ChamberStateHeating
}
// Ensure compressor cooldown
if p.chamberState != ChamberStateCooling && next == ChamberStateCooling &&
time.Since(p.lastCoolerStateChange).Seconds() < p.config.Limits.MinCoolerCooldownSecs {
return ChamberStateIdle
}
// Ensure that heater wont run just after cooling cycle
if p.chamberState != ChamberStateHeating && next == ChamberStateHeating && time.Since(p.lastCoolerStateChange).Seconds() < p.config.Limits.HeaterGraceTimeSecs {
return ChamberStateIdle
}
// Force idle mode when temperature updates are missing
if time.Since(p.lastTemperatureUpdate) > 1*time.Minute {
err := fmt.Errorf("no temperature update on controller for 1 minute - setting IDLE mode")
p.hub.CaptureException(err)
log.Print(err)
next = ChamberStateIdle
}
return next
}