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.
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.
Signal architecture
One sensor feeds three independent decision loops; only the IR LED talks to the AC unit itself.
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.
Pin reference
DIP and mode pins use INPUT_PULLUP and are read inverted (!digitalRead) — switch closed = logic true.
| Pin | Symbol | Mode | Function |
|---|---|---|---|
| GPIO4 | HUMIDIFIER_OFF_PIN | OUTPUT | Humidifier relay — driven HIGH to turn the humidifier on despite the pin's name |
| GPIO7 | ac_disabled_pin | INPUT_PULLUP | Front-panel "AC on" button, read inverted; despite the name it enables the active mode, not disables it |
| GPIO7 | IR_SEND_PIN | IRremote TX | IRremote's default send pin for ESP32-C3 — shares the number with ac_disabled_pin above (see Notes) |
| GPIO8 | HEATER_MODE | INPUT_PULLUP | Boot-time jumper: selects heater logic vs. AC/cooling logic for the whole session |
| GPIO9 | dip_switch_a | INPUT_PULLUP | Setpoint offset bit, weight 0.5°C |
| GPIO10 | dip_switch_b | INPUT_PULLUP | Setpoint offset bit, weight 1.0°C |
| GPIO11 | dip_switch_c | INPUT_PULLUP | Setpoint offset bit, weight 2.0°C |
| GPIO12 | dip_switch_d | INPUT_PULLUP | Sign bit — negates the combined a+b+c offset |
| GPIO14 | humidifier_indication | — (unset) | Defined but never given a pinMode or written — dead declaration |
| I²C bus | SDA/SCL | Wire | Shared by the HDC1080 sensor (0x40) and the LCD backpack (0x27) |
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 value | Meaning |
|---|---|
| AC_OFF | Idle; re-sends the "off" IR code every REPEAT_ACTION_INTERVAL as a keep-alive |
| BOUNCING_FOR_AC_ON | Heat index above max and humidity above threshold for > MAX_BOUNCE_COUNT ticks |
| AC_ON | Cooling active; re-sends the "cool" IR code periodically as a keep-alive |
| BOUNCING_FOR_AC_OFF | Heat index below min or humidity below zero-level for > MAX_BOUNCE_COUNT ticks |
| BOUNCING_FOR_AC_DISABLE | Front-panel button released — debounced before honoring a manual power-off |
| AC_DISABLED | Manually disabled via the front-panel button; climate logic paused |
| BOUNCING_FOR_AC_ENABLE | Front-panel button pressed while disabled — debounced re-enable |
| COOL_TEMPERATURE_SENSOR_FAULT | HDC1080 reading out of plausible range; no IR sent, display-only |
heater_processing — heating hysteresis (active when HEATER_MODE jumper is closed)
| Enum value | Meaning |
|---|---|
| HEATING_DISABLE | Idle; room temp above min_temp, or waiting for the enable debounce |
| BOUNCING_FOR_HEATING_ENABLE | Room temp below min_temp for > MAX_BOUNCE_COUNT ticks |
| HEATING_ENABLE | Heating active; re-sends the "heat" IR code periodically |
| BOUNCING_FOR_HEATING_DISABLE | Room temp above max_temp for > MAX_BOUNCE_COUNT ticks |
| HEATER_OFF / BOUNCING_FOR_HEATER_ON / BOUNCING_FOR_HEATER_OFF | Front-panel button debounce chain, mirrors the AC branch |
| HEATER_TEMPERATURE_SENSOR_FAULT | HDC1080 reading out of plausible range; no IR sent, display-only |
humidifier_processing — independent of AC/heater mode
| State | Behavior | Next state |
|---|---|---|
| HUMIDIFIER_OFF | Idle while the AC/heater is on and humidity is above MIN_HUMD | HUMIDIFIER_ON_BOUNCE |
| HUMIDIFIER_ON_BOUNCE | One-tick confirmation that humidity is still below MIN_HUMD | HUMIDIFIER_ON |
| HUMIDIFIER_ON | Relay energized; waits for humidity to exceed MAX_HUMD | HUMIDIFIER_OFF_BOUNCE |
| HUMIDIFIER_OFF_BOUNCE | One-tick confirmation before de-energizing the relay | HUMIDIFIER_OFF |
Function reference
Sensing & setpoint
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.
Cooling-mode hysteresis state machine. Combines a heat-index band with an independent humidity floor before committing to AC_ON.
Heating-mode hysteresis state machine, structurally identical to ac_processing but keyed on raw temperature rather than heat index.
Independent humidity-only hysteresis; only runs while the front-panel button reports the system as on.
IR dispatch
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.
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.
Fault handlers for an out-of-range sensor reading.
Display & status
Map the active enum to a 16-character LCD-safe status string, e.g. "BOUNC_FOR_AC_OFF".
Formats the countdown to the next periodic IR keep-alive resend as mm:ss for the top-right of the LCD.
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.
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
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.
PinDefinitionsAndMore.h, #define ac_disabled_pin 7Sensor 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.
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.
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.
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.
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.
loop()