Embedded firmware · ESP32-C3 · C++/Arduino · IR

Weather Station Midea

A closed-loop climate controller that reads room temperature and humidity, computes a heat-index setpoint band, and drives an unmodified Midea split AC unit purely by replaying captured infrared remote codes — no serial or wired link into the AC itself. An independent humidifier relay and a jumper-selected heater mode share the same sensor and hysteresis logic.

MCU: ESP32-C3 Sensor: HDC1080 (I²C 0x40) Display: 16×4 I²C LCD (0x27) Loop cadence: 1 Hz (blocking) github.com/amd858/weather_station_midea ↗
01 · What it does

Overview

The AC unit is treated as a black box: the firmware never talks to it electrically. Instead it captured the unit's own IR remote codes ahead of time — using a 555-timer monostable to clean up the receiver's noisy output and a logic analyzer to record the resulting pulse train — and now simply replays those exact waveforms through an IR LED whenever the climate logic decides the room needs cooling, heating, or the unit should turn off.

Every second, an HDC1080 sensor reports temperature and humidity. Humidity is adjusted by a fixed calibration offset, then a heat index is computed and checked against a hysteresis band around a configurable setpoint. Two independent state machines exist for this decision — one for cooling (ac_processing), one for heating (heater_processing) — but only one is ever active, chosen by a physical jumper (HEATER_MODE) read once at boot. A third state machine (humidifier_processing) runs independently of both, driving a relay purely off room humidity.

A bank of four DIP switches lets you nudge the target setpoint by up to ±3.5°C in 0.5° steps without reflashing — read as a binary-weighted offset and applied identically to whichever mode is active.

02 · Signal flow

Signal architecture

One sensor feeds three independent decision loops; only the IR LED talks to the AC unit itself.

Signal flow diagram HDC1080 sensor and DIP switches feed the ESP32-C3, which drives an IR LED toward the Midea AC unit, a humidifier relay, and a 16 by 4 LCD. HDC1080 (I²C temp/humidity) 4× DIP offset switches Heater-mode jumper + AC-on button ESP32-C3 1s sample-and-decide loop IR LED → Midea AC (black box) Humidifier relay 16×4 I²C LCD status

The AC connection is one-way and open-loop — the firmware has no way to confirm the unit actually received a code, hence the periodic re-send described in Tuning.

03 · Hardware map

Pin reference

DIP and mode pins use INPUT_PULLUP and are read inverted (!digitalRead) — switch closed = logic true.

PinSymbolModeFunction
GPIO4HUMIDIFIER_OFF_PINOUTPUTHumidifier relay — driven HIGH to turn the humidifier on despite the pin's name
GPIO7ac_disabled_pinINPUT_PULLUPFront-panel "AC on" button, read inverted; despite the name it enables the active mode, not disables it
GPIO7IR_SEND_PINIRremote TXIRremote's default send pin for ESP32-C3 — shares the number with ac_disabled_pin above (see Notes)
GPIO8HEATER_MODEINPUT_PULLUPBoot-time jumper: selects heater logic vs. AC/cooling logic for the whole session
GPIO9dip_switch_aINPUT_PULLUPSetpoint offset bit, weight 0.5°C
GPIO10dip_switch_bINPUT_PULLUPSetpoint offset bit, weight 1.0°C
GPIO11dip_switch_cINPUT_PULLUPSetpoint offset bit, weight 2.0°C
GPIO12dip_switch_dINPUT_PULLUPSign bit — negates the combined a+b+c offset
GPIO14humidifier_indication— (unset)Defined but never given a pinMode or written — dead declaration
I²C busSDA/SCLWireShared by the HDC1080 sensor (0x40) and the LCD backpack (0x27)
04 · Control flow

State machines

All three machines share the same debounce shape: a steady state, a "bouncing" transitional state that must hold for several ticks before committing, and a fault/disabled branch.

ac_processing — cooling hysteresis (active when HEATER_MODE jumper is open)

Enum valueMeaning
AC_OFFIdle; re-sends the "off" IR code every REPEAT_ACTION_INTERVAL as a keep-alive
BOUNCING_FOR_AC_ONHeat index above max and humidity above threshold for > MAX_BOUNCE_COUNT ticks
AC_ONCooling active; re-sends the "cool" IR code periodically as a keep-alive
BOUNCING_FOR_AC_OFFHeat index below min or humidity below zero-level for > MAX_BOUNCE_COUNT ticks
BOUNCING_FOR_AC_DISABLEFront-panel button released — debounced before honoring a manual power-off
AC_DISABLEDManually disabled via the front-panel button; climate logic paused
BOUNCING_FOR_AC_ENABLEFront-panel button pressed while disabled — debounced re-enable
COOL_TEMPERATURE_SENSOR_FAULTHDC1080 reading out of plausible range; no IR sent, display-only

heater_processing — heating hysteresis (active when HEATER_MODE jumper is closed)

Enum valueMeaning
HEATING_DISABLEIdle; room temp above min_temp, or waiting for the enable debounce
BOUNCING_FOR_HEATING_ENABLERoom temp below min_temp for > MAX_BOUNCE_COUNT ticks
HEATING_ENABLEHeating active; re-sends the "heat" IR code periodically
BOUNCING_FOR_HEATING_DISABLERoom temp above max_temp for > MAX_BOUNCE_COUNT ticks
HEATER_OFF / BOUNCING_FOR_HEATER_ON / BOUNCING_FOR_HEATER_OFFFront-panel button debounce chain, mirrors the AC branch
HEATER_TEMPERATURE_SENSOR_FAULTHDC1080 reading out of plausible range; no IR sent, display-only

humidifier_processing — independent of AC/heater mode

StateBehaviorNext state
HUMIDIFIER_OFFIdle while the AC/heater is on and humidity is above MIN_HUMDHUMIDIFIER_ON_BOUNCE
HUMIDIFIER_ON_BOUNCEOne-tick confirmation that humidity is still below MIN_HUMDHUMIDIFIER_ON
HUMIDIFIER_ONRelay energized; waits for humidity to exceed MAX_HUMDHUMIDIFIER_OFF_BOUNCE
HUMIDIFIER_OFF_BOUNCEOne-tick confirmation before de-energizing the relayHUMIDIFIER_OFF
05 · Function reference

Function reference

Sensing & setpoint

Dip_Switch_Offset()

Reads the four DIP pins, combines a/b/c as a binary-weighted 0–3.5°C magnitude, and negates it if the sign bit (d) is set. Result is added directly to the active mode's setpoint band.

Heater_Dip_Switch_Offset() is an identical, unused duplicate of this function
ac_processing(min_hidx, max_hidx, room_temp, room_humd, room_hidx, reading_error, ac_on_button_on_box)

Cooling-mode hysteresis state machine. Combines a heat-index band with an independent humidity floor before committing to AC_ON.

heater_processing(min_temp, max_temp, room_temp, reading_error, ac_on_button_on_box)

Heating-mode hysteresis state machine, structurally identical to ac_processing but keyed on raw temperature rather than heat index.

humidifier_processing(room_humd, air_conditioner_button_is_on)

Independent humidity-only hysteresis; only runs while the front-panel button reports the system as on.

IR dispatch

sendProntoFromProgmem(button_code[])

Reads the Pronto-format code length from the PROGMEM array header, copies it into a RAM buffer, and calls irsend.sendPronto(). A fixed 6ms delay follows every send.

ac_cooling_enable() / ac_cooling_disable() / heating_enable() / heating_disable()

Each dispatches one or two captured codes for a fixed setpoint baked into the capture (25°C cool, 22°C heat) — the DIP-switch offset changes when these fire, not what temperature they command the AC to.

ac_on_sensor_fault() / heater_on_sensor_fault()

Fault handlers for an out-of-range sensor reading.

Both bodies are fully commented out — no IR command is sent on sensor fault, the unit is simply left in whatever state it was already in

Display & status

ac_state_lcd() / heater_state_lcd()

Map the active enum to a 16-character LCD-safe status string, e.g. "BOUNC_FOR_AC_OFF".

repeat_action_remaining_time_string_get()

Formats the countdown to the next periodic IR keep-alive resend as mm:ss for the top-right of the LCD.

06 · IR capture & replay

IR capture & replay

Rather than implementing Midea's IR bit protocol from a spec, the codes were captured directly from the unit's own remote and are replayed byte-for-byte.

Capture pipeline

1. Remote's IR receiver diode feeds a 555-timer monostable, which cleans bursty/noisy pulses into one uniform pulse per transition.
2. A logic analyzer + PulseView (sigrok) records the cleaned waveform at 1 MHz.
3. Transitions are exported as timestamped high/low durations — a Gray-code-like edge list.
4. An offline script converts the edge list into Pronto hex and a PROGMEM uint16_t[] array.

Runtime replay

const PROGMEM uint16_t cool_25_on_a[] = {
  0, 109, 100, 0, 168, 166, 21, 61, …
};
sendProntoFromProgmem((uint16_t*)(cool_25_on_a));
sendProntoFromProgmem((uint16_t*)(cool_25_on_b));
// most commands are captured as an "a" and "b" half

Six codes are baked in: cool_25_off, cool_25_on_a/b, heat_22_off, heat_22_on_a/b. There is no general-purpose "set to N°C" command — the firmware always commands the same two fixed setpoints, using its own sensor loop (not the AC's internal thermostat) to decide when those commands fire.

07 · Tuning constants

Tuning constants

All thresholds are plain globals at the top of the sketch.

Cooling band (heat index, °C)

const float HC_MAX_HIDEX = 27.2;
const float HC_MIN_HIDEX = 26.8;
const float ABOVE_ZERO_LEVEL_HUMD = 33;
const float ZERO_LEVEL_HUMD = 30;

Tune if: AC cycles too often → widen the 0.4° band. Never triggers → check the humidity gate too.

Heating band (room temp, °C)

min_temp = 19.8 + dip_offset;
max_temp = 20.2 + dip_offset;

Tune if: Room runs cold/hot vs. the DIP-switch offset expectation — recheck sensor calibration first.

Humidifier band (% RH)

const float MIN_HUMD = 39;  // was 55
const float MAX_HUMD = 43;  // was 69

Commented-out prior values (55/69) suggest the band was retuned significantly lower at some point.

Debounce & timing

const int MAX_BOUNCE_COUNT = 6;      // ~6s hysteresis debounce
const int BUTTON_BOUNCE_COUNT = 1;   // ~1s button debounce
const long REPEAT_ACTION_INTERVAL = 600000; // 10 min IR keep-alive

Sensor validity gate

if ((room_temp > 10 && room_temp < 60) && (room_humd > 20 && room_humd < 95)) {
  room_hidx = dht.computeHeatIndex(room_temp, room_humd, false);
  reading_error = !(room_hidx >= 1 && room_hidx <= 95);
} else { reading_error = true; }
// room_humd is read with a fixed -15 calibration offset applied first
08 · Notes & known quirks

Notes & known quirks

IR send pin collides with the front-panel button pin: IRremote's default IR_SEND_PIN for ESP32-C3 is GPIO7 — the same GPIO number assigned to ac_disabled_pin. If both are wired as documented, one of them needs to be remapped in PinDefinitionsAndMore.h.

See PinDefinitionsAndMore.h, #define ac_disabled_pin 7

Sensor fault handlers are no-ops: ac_on_sensor_fault() and heater_on_sensor_fault() have their IR-send bodies fully commented out, so a sensor fault freezes the AC/heater in its last commanded state rather than forcing it off.

See ac_on_sensor_fault(), heater_on_sensor_fault()

"HUMIDIFIER_OFF_PIN" is driven HIGH to turn the humidifier on: the naming reads as an active-off pin but humidifier_on() sets it HIGH and humidifier_off() sets it LOW.

See humidifier_on(), humidifier_off()

DHTesp is used only as a math helper, no DHT sensor is read: the dht object is never begin()'d to a pin — it exists solely to call computeHeatIndex() on the HDC1080's readings.

See global DHTesp dht; and its one call site in loop()

Dead constants from an earlier UI: KEY_UP_TIME, KEY_DOWN_TIME, TEMP_UP_STEPS, and temp_down_steps are declared but never referenced anywhere in the sketch.

See top-of-file constant declarations

Loop is fully blocking: unlike the other two firmware projects in this portfolio, this sketch ends every iteration with a plain delay(1000) — there is no non-blocking tick scheduler here.

See end of loop()