TL;DR — An ESP32-C3 runs a tiny web server; hitting /hood/on should make an SG90 servo press my range hood’s button. The endpoint answered every time, but the servo didn’t move. Over one evening Gemini and I went through PWM timers, attach/detach timing, pulse widths, logic levels, JTAG pins and an LED on the data line. The actual cause: I was resting header pins in the board’s holes without soldering. A data signal (~1mA) gets through a loose contact; a motor pulling a few hundred mA doesn’t. Lesson: check the physical layer first, and change one thing at a time.

This is the actuator half of a home project. A CO2/VOC air-quality sensor tells a server when the air in my studio is bad, and the server needs some way to switch on the kitchen range hood. The hood has a physical rocker button and nothing else — no smart plug, no API — so the plan was a servo that physically pushes the button, driven by an ESP32 on Wi-Fi.

Hardware: an ESP32-C3 SuperMini and a TowerPro-compatible SG90 9g micro servo, both the cheapest versions I could find.

The setup

                    Wi-Fi (2.4GHz)
  server ──── GET /hood/on ────▶ ESP32-C3 SuperMini
                                   │  5V  ──── red    ┐
                                   │  GND ──── brown  ├─ SG90 ─▶ pushes hood button
                                   │  GPIO ─── orange ┘
                                   ▼
                                 USB power

The firmware is about as simple as it gets:

WebServer server(80);
Servo hoodServo;

void pressButton() {
  hoodServo.write(135);  // swing the arm into the button
  delay(400);
  hoodServo.write(90);   // back to rest
  delay(400);
}

void handleOn() {
  pressButton();
  server.send(200, "text/plain", "Hood ON triggered");
}

The symptom: GET /hood/on returned Hood ON triggered every time. The motor never moved.

Round 1: every software theory

Since the HTTP side worked, everything pointed at the motor code. Here’s what got tried, roughly in order:

TheoryChangeResult
ESP32-C3 needs PWM timers allocated explicitlyESP32PWM::allocateTimer(0..3), setPeriodHertz(50)no movement
Detaching right after attaching cuts the pulseattach once in setup(), add a boot-time test sweepa small ticking sound
Sweeping to 0° jams a clone servo against its end stopstandalone sketch, sweep 90° ↔ 135°, narrower pulse widthssilence
Library default pulse widths don’t suit clonesattach(pin) instead of attach(pin, 500, 2400)silence
GPIO 4 doubles as a JTAG pinmove data to GPIO 8a blue light came on
GPIO 8 has the onboard LED, which drags the signal downmove data to GPIO 7blue light gone, still nothing

A few of these are real ESP32-C3 quirks, and the ticking sound did mean the signal was reaching the servo. But look at the blue light. When I moved the data wire to GPIO 8, the onboard LED lit up, and Gemini called it “great news” — a built-in indicator proving the PWM signal was working. A couple of answers later, the same LED was the reason the signal was too weak to read, and the wire had to move again. Both explanations were delivered with the same confidence.

That should have been the moment to stop changing software.

Round 2: wiggle the cable

What actually broke it open wasn’t code:

wait. it worked for a very short moment. when I kind of turned the cable here and there?? is it the loose connection?

It was. Then the follow-up question that closed it:

I am using the friction pins? I am not using soldering

I had pushed the header pins into the SuperMini’s through-holes without soldering them, and plugged the jumper wires onto those. Nothing was holding the pins against the copper rings except friction.

That explains the whole evening:

  • The LED lit up but the motor didn’t move. Driving a logic input or an LED takes around a milliamp, which can cross a bad contact. An SG90 under load pulls a few hundred milliamps. Push that through a loose pin and the contact acts like a resistor; the voltage collapses and the motor gets nothing.
  • Ticking. Just enough current to twitch, never enough to turn.
  • Ticking turned to silence “after changing the pulse width.” It wasn’t the pulse width. The pin had shifted.
  • Every pin move “changed something” because every pin move disturbed the contacts.

With no soldering iron at the time, the fix was mechanical: cut the pins off the jumper wires, strip about 2–3cm of copper, thread it through the 5V, GND and GPIO 7 holes, and twist it tight around itself against the ring. After that:

everything is good.

Six rounds of code changes, and the fix was a twist of copper wire.

Round 3: Wi-Fi that takes “100 tries”

The other long-running problem was connecting. The board needed dozens of attempts, and the serial log was full of:

E (8912) wifi:CCMP replay detected: A1=... A2=... PN=2, RSC=3 seq=464

What helped, or at least made it usable:

WiFi.mode(WIFI_STA);
WiFi.setSleep(false);                 // keep the radio awake (the usual fix for the CCMP replay drops)
WiFi.setTxPower(WIFI_POWER_8_5dBm);   // lower TX power = smaller current spikes on a weak power path
WiFi.disconnect(true);
WiFi.begin(SSID, PASSWORD);

Plus two non-code things: the C3 is 2.4GHz b/g/n only, so I set the router’s 2.4GHz band to g only, and the SuperMini’s tiny ceramic antenna is very directional.

One thing I’d push back on now: the board asked for a static IP and came up on a different, DHCP-assigned one, and Gemini said the router had “rejected” the static IP. A router can’t do that — a static IP never asks the router for anything. More likely something in my own setup code reset the config (WiFi.config() ran before a WiFi.disconnect(true) that was added later). I just dropped the static config and used the DHCP lease.

It’s still not right. The board connects, but pings from about 3 meters away still fail. Gemini’s explanation for that was the twisted wires again — after the loose pin turned out to be real, every later symptom got blamed on the wiring. Maybe. I haven’t proved it.

Round 4: physics, and electricity I’m not touching

With the motor working, two non-software problems showed up.

Mounting. The servo has to push a lever and stay put while it pushes; otherwise it just shoves itself backwards. There was also a gap between any surface I could fix it to and the button. The options worth trying: zip ties through the servo’s mounting ears and the existing holes in the hood bracket, automotive-grade double-sided tape (after degreasing — hoods are coated in a film of cooking oil), epoxy putty as a custom pillar, or simply making the arm longer with a stiff stick so the motor can sit further back.

Power. This is an officetel-style studio: switching the kitchen light off cuts power to the hood circuit too. My automation has to run while I’m asleep, with the light off.

I asked about covering the hood lights with a black sheet so I could leave the circuit on. Don’t — hood lights can run hot, and covering them is a fire risk. I also floated rewiring the hood to another outlet. That means touching 220V mains in a rented flat, so no. The safe options: many hoods in Korean studios plug into a hidden outlet in the cabinet above them, which can be moved to an always-on outlet with a proper power strip; or the wall panel may let the outlets stay on while the lights go off.

That’s where it stands — I still need to look inside that cabinet.

What’s still wrong

Being honest about where this is:

  • The wiring is twisted copper, not solder. Fine for a demo, but it will loosen from the vibration of the servo pressing a button every day.
  • /hood/on and /hood/off do the same thing. Both just press the button once. The hood button is a toggle, and the ESP32 has no idea whether the hood is actually running, so one missed press and the automation is inverted from then on. It needs some state feedback — even a current sensor on the hood’s power cord.
  • The endpoints are unauthenticated GETs. Anyone on my LAN can toggle my range hood. A shared token at minimum.
  • The handlers block. pressButton() sits in delay() for 800ms inside the request handler, so the web server can’t do anything else during a press.
  • Wi-Fi is unreliable. Connecting is slow and pings fail at close range. The real fix is probably a better board with a proper antenna, or soldered power.
  • Servo and radio share USB power. A separate 5V supply for the servo, with a common ground, would stop motor current spikes from starving the radio.

What I’d do differently

  • Check the physical layer first. Before touching code: is every joint actually conducting? A cheap multimeter would have found this in a minute.
  • Change one thing at a time. Several of the rows in that table changed two things at once — a new pin and new pulse widths — and while a contact is intermittent, any change can look like it “worked.”
  • Test the motor alone first. A standalone sweep sketch, no Wi-Fi, should have been step one, not step five.
  • Distrust an explanation that can be rewritten after the fact. The blue LED being both proof the signal worked and the reason it didn’t was the clearest sign the diagnosis was guessing. Gemini is fast at listing plausible causes; it can’t feel a loose pin. The cable wiggle was mine.

And buy a soldering iron.