package controllers import ( "context" "fmt" "log" "sync" "time" "git.joco.dk/snr/fermentord/internal/configuration" "git.joco.dk/snr/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 }