package controllers import ( "context" "sync" "time" "git.joco.dk/sng/fermentord/pkg/temperature" "github.com/rs/zerolog/log" ) type ChamberState int const ( ChamberStateIdle ChamberState = iota ChamberStateCooling ChamberStateHeating ) 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.Warn(). Str("sensor", t.Sensor). Int64("temp", t.MilliDegrees). Msg("Unknown sensor") } } func (p *ChamberController) setChamberState(state ChamberState) { if state == p.chamberState { return } log.Info(). Int("from", int(p.chamberState)). Int("to", int(state)). Msgf("State changed") 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 log.Debug(). Float64("amb_temp", p.ambientTemperature). Float64("chamber_temp", p.chamberTemperature). Float64("wort_temp", p.wortTemperature). Float64("setpoint_temp", p.config.FermentationTemperature). Float64("offset", offset). Float64("chamber_target_temp", chamberTargetTemp). Float64("kp", p.pid.kp). Float64("ki", p.pid.ki). Float64("kd", p.pid.kd). Send() 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! 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 }