Inkbird IVC-001W Fan Controller

I recently acquired a duct booster fan to exhaust my coat closet / server closet. It came with a Tuya compatible module but there wasn’t any support out there for it so I ended up replacing the WBR3 with a ESP12F and ESPHOME firmware. I didn’t add support for all the features this thing comes with because I deemed them unnecessary. I added the PID component for temperature control because I wasn’t satisfied with the bang bang controller built into the unit. This firmware allows me to constantly adjust the set point so that it’s +5ºF above the temperature outside the closet.

If anyone else out there has this fan and wants to integrate it into Home Assistant this firmware config could be useful. I’ve left off some of the basics here, but if you just create a new project in ESPHOME builder it will add that stuff for you automatically.

captive_portal:

uart:
  rx_pin: GPIO3
  tx_pin: GPIO1
  baud_rate: 9600

time:
  - platform: homeassistant
    id: my_time

# Register the Tuya MCU connection
tuya:
  time_id: my_time
  id: tuya_mcu
  on_datapoint_update:
    - sensor_datapoint: 16
      datapoint_type: uint
      then:
        lambda: !lambda |-
          int16_t min_temp_local = (int16_t)(0xFFFF & x);
          int16_t max_temp_local = (int16_t)(0xFFFF & (x >> 16));
          if ((float)min_temp_local/10.0f != id(min_temp_store).state)
            id(min_temp_store).publish_state(min_temp_local/10.0f);
          if ((float)max_temp_local/10.0f != id(max_temp_store).state)
            id(max_temp_store).publish_state(max_temp_local/10.0f);

    - sensor_datapoint: 101
      datapoint_type: raw
      then:
        lambda: !lambda |-
          if(id(manual_fan_speed_store).state != x[0])
            id(manual_fan_speed_store).publish_state(x[0]);

    - sensor_datapoint: 102
      datapoint_type: uint
      then:
        lambda: !lambda |-
          uint16_t min_humid_local = (uint16_t)(0xFFFF & x);
          uint16_t max_humid_local = (uint16_t)(0xFFFF & (x >> 16));
          if ((float)min_humid_local/10.0f != id(min_humid_store).state)
            id(min_humid_store).publish_state(min_humid_local/10.0f);
          if ((float)max_humid_local/10.0f != id(max_humid_store).state)
            id(max_humid_store).publish_state(max_humid_local/10.0f);

    # Byte0 indicates the working mode (Bit7~BIT4 indicates the mode), Bit3 indicates whether the fan is running (0 indicates off,  1 indicates running); Bit2 indicates the temperature scale\\n
    # Byte1~2 indicates the current temperature value ℃, 
    # Byte3~4 indicates the current temperature value ℉ 
    # Byte5~6 indicates the current humidity
    # BYTE7: Wind speed\\n
    # BYTE8~9: High temperature preset value
    # BYTE10~11: Low temperature preset value
    # BYTE12~13: High humidity preset value
    # BYTE14~15: Low humidity preset value
    # BYTE16: Alarm status
    # BYTE17: Alarm upload flag (0: not upload; 1: upload)
    # BYTE18~19: Fan running time
    - sensor_datapoint: 132
      datapoint_type: raw
      then: 
        lambda: !lambda |-
          uint8_t mode = x[0] >> 4;
          switch(mode) {
            case 0:
              id(operating_mode).publish_state("Auto");
              break;
            case 1:
              id(operating_mode).publish_state("Manual");
              break;
            case 2:
              id(operating_mode).publish_state("Timer");
              break;
          }
          uint8_t fan_running = (x[0] & 0b1000) > 0;
          id(fan_status).publish_state(fan_running);
          uint8_t temp_scale = (x[0] & 0b100) > 0;
          if(temp_scale) id(temp_unit).publish_state("F");
          else id(temp_unit).publish_state("C");
          uint16_t curr_temp_c_int = x[1] * 256;
          curr_temp_c_int += x[2];
          float curr_temp_c = (int16_t)curr_temp_c_int;
          id(temp_c).publish_state(curr_temp_c/10.0f);
          uint16_t curr_temp_f_int = x[3] * 256;
          curr_temp_f_int += x[4];
          float curr_temp_f = (int16_t)curr_temp_f_int;
          id(temp_f).publish_state(curr_temp_f/10.0f);
          uint16_t curr_humid_int = x[5] * 256;
          curr_humid_int += x[6];
          float curr_humid = (int16_t)curr_humid_int;
          id(humid).publish_state(curr_humid/10.0f);
          uint8_t fan_speed = x[7];
          id(curr_fan_speed).publish_state(fan_speed);

text_sensor:
  - platform: template
    name: "Operating Mode"
    id: operating_mode

  - platform: template
    name: "Temperature Units"
    id: temp_unit

binary_sensor:
  - platform: template
    name: "Fan Running"
    id: fan_status

sensor:
  - platform: template
    name: "Temperature °C"
    unit_of_measurement: "°C"
    icon: "mdi:thermometer"
    device_class: temperature
    state_class: measurement
    id: temp_c

  - platform: template
    name: "Temperature °F"
    unit_of_measurement: "°F"
    icon: "mdi:thermometer"
    device_class: temperature
    state_class: measurement
    id: temp_f

  - platform: template
    name: "Humidity"
    unit_of_measurement: "%"
    icon: "mdi:water-percent"
    device_class: humidity
    state_class: measurement
    id: humid

  - platform: template
    name: "Fan Speed"
    icon: "mdi:speedometer"
    state_class: measurement
    id: curr_fan_speed

  - platform: pid
    name: "PID Climate Result"
    type: RESULT

  - platform: pid
    name: "PID Climate Error"
    type: ERROR

  - platform: pid
    name: "PID Climate Proportional"
    type: PROPORTIONAL
    
  - platform: pid
    name: "PID Climate Integral"
    type: INTEGRAL
    
  - platform: pid
    name: "PID Climate Derivative"
    type: DERIVATIVE
    
  - platform: pid
    name: "PID Climate KP"
    type: KP
    
  - platform: pid
    name: "PID Climate KI"
    type: KI
    
  - platform: pid
    name: "PID Climate KD"
    type: KD
    
# Power switch
switch:
  - platform: "tuya"
    name: "Power"
    switch_datapoint: 1
    inverted: True

  - platform: "tuya"
    name: "Child Lock"
    switch_datapoint: 107

  - platform: "tuya"
    name: "Query Device"
    switch_datapoint: 109

  - platform: template
    id: max_temp_disable
    name: "Disable Max Temperature"
    lambda: !lambda |-
      return (id(max_temp_store).state == 0x7FFF);
    turn_on_action: 
      then:
        lambda: !lambda |-
          id(max_temp_store).publish_state(0x7FFF);
    turn_off_action: 
      then:
        lambda: !lambda |-
          id(max_temp_store).publish_state(750);

  - platform: template
    id: min_temp_disable
    name: "Disable Min Temperature"
    lambda: !lambda |-
      return (id(min_temp_store).state == 0x7FFF);
    turn_on_action: 
      then:
        lambda: !lambda |-
          id(min_temp_store).publish_state(0x7FFF);
    turn_off_action: 
      then:
        lambda: !lambda |-
          id(min_temp_store).publish_state(350);

  - platform: template
    id: max_humid_disable
    name: "Disable Max Humidity"
    lambda: !lambda |-
      return (id(max_humid_store).state == 0x7FFF);
    turn_on_action: 
      then:
        lambda: !lambda |-
          id(max_humid_store).publish_state(0x7FFF);
    turn_off_action: 
      then:
        lambda: !lambda |-
          id(max_humid_store).publish_state(600);

  - platform: template
    id: min_humid_disable
    name: "Disable Min Humidity"
    lambda: !lambda |-
      return (id(min_humid_store).state == 0x7FFF);
    turn_on_action: 
      then:
        lambda: !lambda |-
          id(min_humid_store).publish_state(0x7FFF);
    turn_off_action: 
      then:
        lambda: !lambda |-
          id(min_humid_store).publish_state(500);
          
# Operating Mode
select:
  - platform: "tuya"
    name: "Operating Mode"
    enum_datapoint: 2
    options:
      0: Automatic
      1: Manual
      2: Timer

  - platform: "tuya"
    name: "Temperature Unit"
    enum_datapoint: 23
    options:
      0: C
      1: F

number:
  - platform: template
    min_value: -40
    max_value: 0x7FFF
    step: 1
    id: min_temp_store
    internal: True
    optimistic: True

  - platform: template
    min_value: -40
    max_value: 0x7FFF
    step: 1
    id: max_temp_store
    internal: True
    optimistic: True

  - platform: template
    name: "Minimum Temperature"
    min_value: -40
    max_value: 212
    step: 0.1
    id: min_temp
    icon: "mdi:thermometer"
    lambda: !lambda |-
      return id(min_temp_store).state;
    set_action: 
      then: 
        lambda: !lambda |-
          id(min_temp_store).publish_state(x);
          int16_t max_temp = id(max_temp_store).state*10;
          int16_t min_temp = (int16_t)x*10;
          uint32_t min_max = ((int16_t)max_temp << 16) & min_temp;
          id(tuya_mcu).set_integer_datapoint_value(16, min_max);

  - platform: template
    name: "Maximum Temperature"
    min_value: -40
    max_value: 212
    step: 0.1
    id: max_temp
    icon: "mdi:thermometer"
    lambda: !lambda |-
      return id(max_temp_store).state;
    set_action: 
      then: 
        lambda: !lambda |-
          id(max_temp_store).publish_state(x);
          int16_t max_temp = (int16_t)x*10;
          int16_t min_temp = id(min_temp_store).state*10;
          uint32_t min_max = (max_temp << 16) & min_temp;
          id(tuya_mcu).set_integer_datapoint_value(16, min_max);

  - platform: template
    min_value: 0
    max_value: 0x7FFF
    step: 1
    id: min_humid_store
    internal: True
    optimistic: True

  - platform: template
    min_value: 0
    max_value: 0x7FFF
    step: 1
    id: max_humid_store
    internal: True
    optimistic: True

  - platform: template
    name: "Minimum Humidity"
    min_value: 0
    max_value: 100
    step: 0.1
    id: min_humid
    icon: "mdi:water-percent"
    lambda: !lambda |-
      return id(min_humid_store).state;
    set_action: 
      then: 
        lambda: !lambda |-
          id(min_humid_store).publish_state(x);
          uint16_t max_humid = id(max_humid_store).state*10;
          uint16_t min_humid = (uint16_t)x*10;
          uint32_t min_max = ((uint16_t)max_humid << 16) & min_humid;
          id(tuya_mcu).set_integer_datapoint_value(16, min_max);

  - platform: template
    name: "Maximum Humidity"
    min_value: 0
    max_value: 100
    step: 0.1
    id: max_humid
    icon: "mdi:water-percent"
    lambda: !lambda |-
      return id(max_humid_store).state;
    set_action: 
      then: 
        lambda: !lambda |-
          id(max_humid_store).publish_state(x);
          uint16_t max_humid = (uint16_t)x*10;
          uint16_t min_humid = id(min_humid_store).state*10;
          uint32_t min_max = (max_humid << 16) & min_humid;
          id(tuya_mcu).set_integer_datapoint_value(16, min_max);

  - platform: "tuya"
    name: "Backlight Brightness"
    number_datapoint: 44
    min_value: 0
    max_value: 100
    step: 1

  - platform: template
    min_value: 0
    max_value: 0x7FFF
    step: 1
    id: manual_fan_speed_store
    internal: True
    optimistic: True

  - platform: template
    name: "Manual Fan Speed"
    min_value: 0
    max_value: 10
    step: 1
    id: manual_fan_speed
    icon: "mdi:fan"
    lambda: !lambda |-
      return id(manual_fan_speed_store).state;
    set_action: 
      then: 
        lambda: !lambda |-
          id(manual_fan_speed_store).publish_state(x);
          id(tuya_mcu).set_raw_datapoint_value(101, {(uint8_t)x, 0, 0, 0, 0});
    
  - platform: "tuya"
    name: "Auto Fan On Speed"
    number_datapoint: 103
    min_value: 0
    max_value: 10
    step: 1

  - platform: "tuya"
    name: "Auto Fan Off Speed"
    number_datapoint: 104
    min_value: 0
    max_value: 10
    step: 1

output:
  - platform: template
    type: float
    id: fan_speed_scaled
    min_power: 0.1
    max_power: 1
    write_action:
      then:
        lambda: !lambda |-
           id(manual_fan_speed).publish_state((uint8_t)(state*10+0.5));
           id(tuya_mcu).set_raw_datapoint_value(101, {(uint8_t)(state*10+0.5), 0, 0, 0, 0});
           
    
climate:
  - platform: pid
    id: pid_climate
    name: "PID Climate Controller"
    sensor: temp_c
    default_target_temperature: 23.3°C
    cool_output: fan_speed_scaled
    control_parameters:
      kp: 0.76394
      ki: 0.00219
      kd: 66.59221
    deadband_parameters:
      threshold_high: 0.5°C
      threshold_low: -1.0°C
      kp_multiplier: 0.5
      ki_multiplier: 0.5
      kd_multiplier: 0.5
      deadband_output_averaging_samples: 5

# button:
#   - platform: template
#     name: "PID Climate Autotune"
#     on_press:
#       - climate.pid.autotune: pid_climate

# interval:
#   - interval: 5s
#     then:
#       lambda: !lambda |-
#         id(tuya_mcu).set_boolean_datapoint_value(109, true);