PWM PID Fan Controller running 100% since upgrade

For context — a couple of years ago I built a PID Fan Controller on an Olimex EVB board (for POE support) to control a fan affixed to the top of my cabinet.

I haven’t touched it (or my other ESPHome devices) for over a year for the sake of “if it ain’t broke…”, but found myself rattling through them all yesterday to bump them all to the latest version, just to keep on top of things. For more context, with a newborn on the way, I’m not going to have time in a few months… :joy:

Everything else went through fine, however, my fan controller now appears to be stuck at 100% and cannot for the life of me figure out why. I’ve changed nothing in the configuration, other than a few errors regarding configuration changes and deprecations—a single line, here or there.

There is a warning that the fan PWM is configured on GPIO02, a strapping pin, but this wasn’t a problem in the previous build, again, unless framework tweaks… :thinking:

Below is my entire config for the device, not sure if someone can spot anything glaringly obvious.

Apologies it’s a bit of a dump, I thought it would be easier to print out the entire validated config for context-awareness.

Summary
INFO ESPHome 2025.2.2
INFO Reading configuration olimex-server-cabinet.yaml...
INFO Detected timezone 'Europe/London'
WARNING GPIO2 is a strapping PIN and should only be used for I/O with care.
Attaching external pullup/down resistors to strapping pins can cause unexpected failures.
See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins
WARNING GPIO5 is a strapping PIN and should only be used for I/O with care.
Attaching external pullup/down resistors to strapping pins can cause unexpected failures.
See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins
WARNING GPIO2 is a strapping PIN and should only be used for I/O with care.
Attaching external pullup/down resistors to strapping pins can cause unexpected failures.
See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins
substitutions:
  esphome_version: 2025.2.2
  device: evb
  device__friendly: EVB
  pin__tx: '1'
  pin__tx2: '10'
  pin__rx: '3'
  pin__rx2: '9'
  pin__can_tx: '5'
  pin__can_rx: '35'
  pin__i2c_sda: '13'
  pin__i2c_scl: '16'
  pin__spi_cs: '17'
  pin__ir_tx: '12'
  pin__ir_rx: '39'
  pin__relay_1: '32'
  pin__relay_2: '33'
  pin__ethernet_mdc: '23'
  pin__ethernet_mdio: '18'
  pin__hs2_clk: '14'
  pin__hs2_cmd: '15'
  pin__hs2_data: '2'
  pin__button: '34'
  manufacturer: olimex
  manufacturer__friendly: Olimex
  name: olimex-server-cabinet
  name__friendly: Server Cabinet
  description: Olimex EVB configured for monitoring temperature within the Server
    Cabinet, and adjusting it's top-case fan.
  version: 2025.3.4-1
  fan_min: 5%
  fan_max: 100%
  temp_target: '27.5'
  temp_min: '20'
  temp_max: '35'
  pid_kp: '0.90000'
  pid_ki: '0.00135'
  pid_kd: '0.00000'
  cron_autotune: 0 0 0 * * 6
esphome:
  name: olimex-server-cabinet
  comment: Olimex EVB configured for monitoring temperature within the Server Cabinet,
    and adjusting it's top-case fan. (2025.3.4-1)
  project:
    name: jamieshaw.olimex-server-cabinet
    version: 2025.3.4-1
  on_boot:
  - priority: -100.0
    then:
    - delay: 1s
    - binary_sensor.template.publish:
        id: esphome_ready
        state: true
    - logger.log:
        tag: -.ready.-
        level: DEBUG
        format: Server Cabinet has announced 'ready'
        args: []
    - lambda: !lambda |-
        id(thermostat).set_kp(id(thermostat_kp).state);
        id(thermostat).set_ki(id(thermostat_ki).state);
        id(thermostat).set_kd(id(thermostat_kd).state);
  min_version: 2025.2.2
  build_path: build/olimex-server-cabinet
  friendly_name: ''
  area: ''
  platformio_options: {}
  includes: []
  libraries: []
  name_add_mac_suffix: false
esp32:
  board: esp32-evb
  framework:
    version: 2.0.5
    advanced:
      ignore_efuse_custom_mac: false
    source: ~3.20005.0
    platform_version: platformio/[email protected]
    type: arduino
  flash_size: 4MB
  variant: ESP32
logger:
  baud_rate: 115200
  tx_buffer_size: 512
  deassert_rts_dtr: false
  hardware_uart: UART0
  level: DEBUG
  logs: {}
ota:
- platform: esphome
  password: !secret 'ota_password'
  version: 2
  port: 3232
button:
- platform: shutdown
  name: Server Cabinet Shutdown
  disabled_by_default: true
  id: esphome_shutdown
  icon: mdi:power
  entity_category: config
- platform: restart
  name: Server Cabinet Restart
  id: esphome_restart
  disabled_by_default: false
  icon: mdi:restart
  entity_category: config
  device_class: restart
- platform: safe_mode
  name: Server Cabinet Restart (Safe Mode)
  id: esphome_restart_safe
  disabled_by_default: false
  icon: mdi:restart-alert
  entity_category: config
  device_class: restart
- platform: template
  name: Server Cabinet Thermostat Autotune
  entity_category: config
  icon: mdi:car-cruise-control
  on_press:
  - then:
    - script.execute:
        id: autotune
  disabled_by_default: false
api:
  encryption:
    key: !secret 'api_encryption'
  port: 6053
  password: ''
  reboot_timeout: 15min
http_request:
  useragent: olimex-server-cabinet/2025.3.4-1 (Olimex EVB; olimex; evb) ESPHome/2025.2.2
  timeout: 10s
  verify_ssl: false
  follow_redirects: true
  redirect_limit: 3
binary_sensor:
- platform: status
  name: Server Cabinet Status
  id: esphome_status
  disabled_by_default: false
  entity_category: diagnostic
  device_class: connectivity
- platform: template
  name: Server Cabinet Ready
  device_class: running
  entity_category: diagnostic
  id: esphome_ready
  disabled_by_default: false
- platform: template
  name: Server Cabinet Overheat
  device_class: heat
  lambda: !lambda |-
    if (std::isnan(id(thermostat).current_temperature))
      return {};
    else if (id(thermostat).current_temperature > id(temperature_max))
      return true;
    else
      return false;
  id: overheat
  disabled_by_default: false
- platform: template
  name: Server Cabinet Underheat
  device_class: cold
  lambda: !lambda |-
    if (std::isnan(id(thermostat).current_temperature))
      return {};
    else if (id(thermostat).current_temperature < id(temperature_min))
      return true;
    else
      return false;
  id: underheat
  disabled_by_default: false
text_sensor:
- platform: version
  name: Server Cabinet ESPHome Version
  hide_timestamp: true
  icon: mdi:application-import
  id: esphome_version
  disabled_by_default: false
  entity_category: diagnostic
web_server:
  version: 2
  port: !secret 'server_port'
  auth:
    username: !secret 'server_name'
    password: !secret 'server_pass'
  include_internal: true
  ota: true
  enable_private_network_access: true
  log: true
  css_url: ''
  js_url: https://oi.esphome.io/v2/www.js
ethernet:
  mdc_pin: 23
  mdio_pin: 18
  clk_mode: GPIO0_IN
  domain: !secret 'wifi_domain_name'
  phy_addr: 0
  type: LAN8720
globals:
- id: temperature_min
  type: float
  restore_value: false
  initial_value: '20'
- id: temperature_max
  initial_value: '35'
  type: float
  restore_value: false
time:
- platform: sntp
  on_time:
  - then:
    - if:
        condition:
          switch.is_on:
            id: autotune_schedule
        then:
        - script.execute:
            id: autotune
        else:
        - logger.log:
            tag: debug.script.pid.autotune
            level: INFO
            format: 'Ignoring request for scheduled autotuning: schedule not enabled'
            args: []
    seconds:
    - 0
    minutes:
    - 0
    hours:
    - 0
    days_of_month:
    - 1
    - 2
    - 3
    - 4
    - 5
    - 6
    - 7
    - 8
    - 9
    - 10
    - 11
    - 12
    - 13
    - 14
    - 15
    - 16
    - 17
    - 18
    - 19
    - 20
    - 21
    - 22
    - 23
    - 24
    - 25
    - 26
    - 27
    - 28
    - 29
    - 30
    - 31
    months:
    - 1
    - 2
    - 3
    - 4
    - 5
    - 6
    - 7
    - 8
    - 9
    - 10
    - 11
    - 12
    days_of_week:
    - 6
  timezone: GMT0BST,M3.5.0/1,M10.5.0
  update_interval: 15min
  servers:
  - 0.pool.ntp.org
  - 1.pool.ntp.org
  - 2.pool.ntp.org
switch:
- platform: template
  name: Server Cabinet Thermostat Autotune Schedule
  entity_category: config
  restore_mode: RESTORE_DEFAULT_ON
  optimistic: true
  icon: mdi:car-speed-limiter
  id: autotune_schedule
  disabled_by_default: false
  assumed_state: false
number:
- platform: template
  name: Server Cabinet Thermostat KP
  entity_category: config
  unit_of_measurement: '%'
  mode: BOX
  initial_value: 0.9
  step: 0.001
  min_value: 0.0
  max_value: 1.0
  restore_value: true
  optimistic: true
  update_interval: 5s
  icon: mdi:chart-bell-curve
  id: thermostat_kp
  set_action:
    then:
    - lambda: !lambda |-
        id(thermostat).set_kp(x);
  disabled_by_default: false
- platform: template
  name: Server Cabinet Thermostat KI
  id: thermostat_ki
  initial_value: 0.00135
  set_action:
    then:
    - lambda: !lambda |-
        id(thermostat).set_ki(x);
  entity_category: config
  unit_of_measurement: '%'
  mode: BOX
  step: 0.001
  min_value: 0.0
  max_value: 1.0
  restore_value: true
  optimistic: true
  update_interval: 5s
  icon: mdi:chart-bell-curve
  disabled_by_default: false
- platform: template
  name: Server Cabinet Thermostat KD
  id: thermostat_kd
  initial_value: 0.0
  max_value: 20.0
  set_action:
    then:
    - lambda: !lambda |-
        id(thermostat).set_kd(x);
  entity_category: config
  unit_of_measurement: '%'
  mode: BOX
  step: 0.001
  min_value: 0.0
  restore_value: true
  optimistic: true
  update_interval: 5s
  icon: mdi:chart-bell-curve
  disabled_by_default: false
output:
- platform: ledc
  pin:
    number: 2
    allow_other_uses: true
    mode:
      output: true
      input: false
      open_drain: false
      pullup: false
      pulldown: false
    inverted: false
    ignore_pin_validation_error: false
    ignore_strapping_warning: false
    drive_strength: 20.0
  frequency: 25000.0
  min_power: 0.05
  max_power: 1.0
  zero_means_zero: true
  id: output_cool
climate:
- platform: pid
  name: Server Cabinet Thermostat
  sensor: temperature
  cool_output: output_cool
  default_target_temperature: 27.5
  visual:
    min_temperature: 20.0
    max_temperature: 35.0
  control_parameters:
    kp: 0.9
    ki: 0.00135
    kd: 0.0
    starting_integral_term: 0.0
    min_integral: -1.0
    max_integral: 1.0
    derivative_averaging_samples: 1
    output_averaging_samples: 1
  id: thermostat
  on_state:
  - then:
    - if:
        condition:
          binary_sensor.is_on:
            id: esphome_ready
        then:
        - number.set:
            id: thermostat_kp
            value: !lambda |-
              return id(thermostat).get_kp();
        - number.set:
            id: thermostat_ki
            value: !lambda |-
              return id(thermostat).get_ki();
        - number.set:
            id: thermostat_kd
            value: !lambda |-
              return id(thermostat).get_kd();
        - lambda: !lambda |-
            bool actv_thermo = id(thermostat).mode != 0;
            bool actv_fan = id(fan_control).state != 0;

            if (actv_thermo != actv_fan) {
                auto call = (actv_thermo) ? id(fan_control).turn_on() : id(fan_control).turn_off();
                call.perform();
            }
  disabled_by_default: false
fan:
- platform: binary
  name: Server Cabinet Fan
  output: output_cool
  restore_mode: RESTORE_DEFAULT_ON
  id: fan_control
  on_turn_on:
  - then:
    - if:
        then:
        - climate.control:
            id: thermostat
            mode: COOL
        condition:
          binary_sensor.is_on:
            id: esphome_ready
  on_turn_off:
  - then:
    - if:
        then:
        - climate.control:
            id: thermostat
            mode: 'OFF'
        condition:
          binary_sensor.is_on:
            id: esphome_ready
  disabled_by_default: false
sensor:
- platform: pulse_counter
  name: Server Cabinet Fan Speed
  pin:
    number: 5
    mode:
      input: true
      pullup: true
      output: false
      open_drain: false
      pulldown: false
    inverted: false
    ignore_pin_validation_error: false
    ignore_strapping_warning: false
    drive_strength: 20.0
  state_class: measurement
  entity_category: diagnostic
  unit_of_measurement: RPM
  accuracy_decimals: 0
  update_interval: 1s
  filters:
  - multiply: 0.5
  - exponential_moving_average:
      alpha: 0.15
      send_every: 1
      send_first_at: 1
  - or:
    - delta:
        value: 5.0
        type: absolute
    - heartbeat: 60s
  icon: mdi:fan-clock
  id: fan_speed
  disabled_by_default: false
  force_update: false
  count_mode:
    rising_edge: INCREMENT
    falling_edge: DISABLE
  use_pcnt: true
  internal_filter: 13us
- platform: duty_cycle
  name: Server Cabinet Fan Duty
  pin:
    number: 2
    allow_other_uses: true
    mode:
      input: true
      output: false
      open_drain: false
      pullup: false
      pulldown: false
    inverted: false
    ignore_pin_validation_error: false
    ignore_strapping_warning: false
    drive_strength: 20.0
  state_class: measurement
  entity_category: diagnostic
  accuracy_decimals: 0
  update_interval: 1s
  icon: mdi:gauge
  id: fan_duty
  disabled_by_default: false
  force_update: false
  unit_of_measurement: '%'
- platform: pid
  name: Server Cabinet Thermostat P Term
  state_class: measurement
  entity_category: diagnostic
  disabled_by_default: true
  climate_id: thermostat
  type: PROPORTIONAL
  force_update: false
  unit_of_measurement: '%'
  icon: mdi:gauge
  accuracy_decimals: 1
- platform: pid
  name: Server Cabinet Thermostat I Term
  type: INTEGRAL
  state_class: measurement
  entity_category: diagnostic
  disabled_by_default: true
  climate_id: thermostat
  force_update: false
  unit_of_measurement: '%'
  icon: mdi:gauge
  accuracy_decimals: 1
- platform: pid
  name: Server Cabinet Thermostat D Term
  type: DERIVATIVE
  state_class: measurement
  entity_category: diagnostic
  disabled_by_default: true
  climate_id: thermostat
  force_update: false
  unit_of_measurement: '%'
  icon: mdi:gauge
  accuracy_decimals: 1
- platform: pid
  name: Server Cabinet Thermostat Result
  type: RESULT
  icon: mdi:arrow-collapse-vertical
  state_class: measurement
  entity_category: diagnostic
  disabled_by_default: true
  climate_id: thermostat
  force_update: false
  unit_of_measurement: '%'
  accuracy_decimals: 1
- platform: pid
  name: Server Cabinet Thermostat Error
  type: ERROR
  filters:
  - multiply: -1.0
  icon: mdi:sun-snowflake
  state_class: measurement
  entity_category: diagnostic
  disabled_by_default: true
  climate_id: thermostat
  force_update: false
  unit_of_measurement: '%'
  accuracy_decimals: 1
- platform: am2320
  i2c_id: climate_upper
  address: 0x5C
  update_interval: 5s
  setup_priority: -100.0
  temperature:
    name: Server Cabinet Upper Temperature
    id: climate_upper__temperature
    disabled_by_default: false
    force_update: false
    unit_of_measurement: °C
    accuracy_decimals: 1
    device_class: temperature
    state_class: measurement
  humidity:
    name: Server Cabinet Upper Humidity
    id: climate_upper__humidity
    disabled_by_default: false
    force_update: false
    unit_of_measurement: '%'
    accuracy_decimals: 1
    device_class: humidity
    state_class: measurement
- platform: am2320
  i2c_id: climate_lower
  address: 0x5C
  update_interval: 5s
  setup_priority: -100.0
  temperature:
    name: Server Cabinet Lower Temperature
    id: climate_lower__temperature
    disabled_by_default: false
    force_update: false
    unit_of_measurement: °C
    accuracy_decimals: 1
    device_class: temperature
    state_class: measurement
  humidity:
    name: Server Cabinet Lower Humidity
    id: climate_lower__humidity
    disabled_by_default: false
    force_update: false
    unit_of_measurement: '%'
    accuracy_decimals: 1
    device_class: humidity
    state_class: measurement
- platform: template
  name: Server Cabinet Center Temperature
  state_class: measurement
  device_class: temperature
  unit_of_measurement: °C
  update_interval: 5s
  setup_priority: -400.0
  lambda: !lambda |-
    float sum = 0;
    uint16_t cnt = 0;

    if (!std::isnan(id(climate_upper__temperature).state)) {
      sum += id(climate_upper__temperature).state;
      cnt += 1;
    }

    if (!std::isnan(id(climate_lower__temperature).state)) {
      sum += id(climate_lower__temperature).state;
      cnt += 1;
    }

    if (cnt == 0) {
      return NAN;
    } else {
      return ((sum / cnt) * 1.15);
    }
  id: climate_center__temperature
  disabled_by_default: false
  force_update: false
  accuracy_decimals: 1
- platform: template
  name: Server Cabinet Center Humidity
  device_class: humidity
  unit_of_measurement: '%'
  lambda: !lambda |-
    float sum = 0;
    uint16_t cnt = 0;

    if (!std::isnan(id(climate_upper__humidity).state)) {
      sum += id(climate_upper__humidity).state;
      cnt += 1;
    }

    if (!std::isnan(id(climate_lower__humidity).state)) {
      sum += id(climate_lower__humidity).state;
      cnt += 1;
    }

    if (cnt == 0) {
      return NAN;
    } else {
      return ((sum / cnt) * 0.85);
    }
  id: climate_center__humidity
  state_class: measurement
  update_interval: 5s
  setup_priority: -400.0
  disabled_by_default: false
  force_update: false
  accuracy_decimals: 1
- platform: template
  name: Server Cabinet Temperature
  setup_priority: -500.0
  filters:
  - exponential_moving_average:
      alpha: 0.075
      send_every: 1
      send_first_at: 1
  lambda: !lambda |-
    float sum = 0;
    uint16_t cnt = 0;

    if (!std::isnan(id(climate_upper__temperature).state)) {
      sum += id(climate_upper__temperature).state;
      cnt += 1;
    }

    if (!std::isnan(id(climate_center__temperature).state)) {
      sum += id(climate_center__temperature).state;
      cnt += 1;
    }

    if (!std::isnan(id(climate_lower__temperature).state)) {
      sum += id(climate_lower__temperature).state;
      cnt += 1;
    }

    if (cnt == 0) {
      return NAN;
    } else {
      return (sum / cnt);
    }
  id: temperature
  state_class: measurement
  device_class: temperature
  unit_of_measurement: °C
  update_interval: 5s
  disabled_by_default: false
  force_update: false
  accuracy_decimals: 1
- platform: template
  name: Server Cabinet Humidity
  setup_priority: -500.0
  filters:
  - exponential_moving_average:
      alpha: 0.075
      send_every: 1
      send_first_at: 1
  lambda: !lambda |-
    float sum = 0;
    uint16_t cnt = 0;

    if (!std::isnan(id(climate_upper__humidity).state)) {
      sum += id(climate_upper__humidity).state;
      cnt += 1;
    }

    if (!std::isnan(id(climate_center__humidity).state)) {
      sum += id(climate_center__humidity).state;
      cnt += 1;
    }

    if (!std::isnan(id(climate_lower__humidity).state)) {
      sum += id(climate_lower__humidity).state;
      cnt += 1;
    }

    if (cnt == 0) {
      return NAN;
    } else {
      return (sum / cnt);
    }
  id: humidity
  device_class: humidity
  unit_of_measurement: '%'
  state_class: measurement
  update_interval: 5s
  disabled_by_default: false
  force_update: false
  accuracy_decimals: 1
script:
- id: autotune
  then:
  - logger.log:
      format: Starting autotuning process…
      tag: debug.script.pid.autotune
      level: INFO
      args: []
  - climate.pid.autotune:
      id: thermostat
      positive_output: 1.0
      noiseband: 0.25
      negative_output: -1.0
  mode: single
  parameters: {}
i2c:
- scl: 16
  sda: 13
  scan: false
  frequency: 50000.0
  id: climate_upper
- scl: 1
  sda: 3
  id: climate_lower
  scan: false
  frequency: 50000.0

I don’t think you can use binary fan for ledc output. Try with speed fan.
Also you should review the whole setup. Why you waste resources like using duty cycle sensor for output that your code sets the duty cycle in the first place.

Appreciated, probably all a bit of a weird setup. Could just be of the time, looking at my commits, haven’t touched the config since August 2022… :sweat_smile:

The gist is that the climate component controls the ledc output for providing PWM control of the fan. The fan control is probably a bit of a misnomer as it’s kinda just another way of turning the climate between “off” and “cool”.

In end I ended up installing an older version of ESPHome, which allowed for installation of platformio/[email protected] and uploading that binary back to it using the web interface. After that, I bought my ESPHome installation back up to date.

I’ll just leave this device as is for now…

As you want. I’m petty sure it didn’t ask much more time to fix the code though…