package controllers import ( "context" "log" "sync" "time" "git.joco.dk/sng/fermentord/pkg/temperature" ) 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 ControllerConfig config ControllerConfig // Current state. chamberState ChamberState lastChamberStateChange time.Time lastCoolerStateChange time.Time C chan ChamberState name string // Current temperature readings. ambientTemperature float64 chamberTemperature float64 wortTemperature float64 chTemp <-chan temperature.TemperatureReading pid *PIDController } func NewChamberController(name string, config ControllerConfig, chTemp <-chan temperature.TemperatureReading) *ChamberController { return &ChamberController{ C: make(chan ChamberState), name: name, config: config, pid: NewPIDController(config.PID.Kp, config.PID.Ki, config.PID.Kd), chTemp: chTemp, chamberState: ChamberStateIdle, ConfigUpdates: make(chan ControllerConfig, 1), } } func (p *ChamberController) Run(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() ticker := time.NewTicker(1 * time.Second) for { select { case <-ticker.C: state := p.computeChamberState() p.setChamberState(state) case temp := <-p.chTemp: p.setTemperature(temp) case c := <-p.ConfigUpdates: p.config = c case <-ctx.Done(): ticker.Stop() p.setChamberState(ChamberStateIdle) return } } } func (p *ChamberController) setTemperature(t temperature.TemperatureReading) { switch t.Sensor { case p.config.Sensor.Ambient: p.ambientTemperature = t.Degrees() case p.config.Sensor.Chamber: p.chamberTemperature = t.Degrees() case p.config.Sensor.Wort: p.wortTemperature = t.Degrees() default: log.Printf("Unknown sensor: sensor=%v temp=%v", t.Sensor, t.MilliDegrees) } } func (p *ChamberController) setChamberState(state ChamberState) { if state == p.chamberState { 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.wortTemperature, p.config.FermentationTemperature) chamberTargetTemp := p.config.FermentationTemperature + offset 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 } } var next ChamberState heater := p.wortTemperature < p.config.FermentationTemperature-p.config.DeltaTemperature && p.chamberTemperature < chamberTargetTemp-p.config.DeltaTemperature cooler := p.wortTemperature > p.config.FermentationTemperature+p.config.DeltaTemperature && p.chamberTemperature > chamberTargetTemp+p.config.DeltaTemperature if cooler && heater { // This should not happen! log.Print("Trying to set cooler and heater on at the same time. This should NOT happen!") 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 } return next }