AC wall switch: parts, wiring, program
Servo-actuated rocker flipper · ESP32 + Cloudflare Worker · compiled 11 Sep 2026 from the Taobao cart (11 Aug 2026), the firmware and the project README
A servo mounted beside the AC's wall rocker switch presses it over the internet. Nothing touches mains wiring: the servo pushes the existing switch the way a finger would. A temperature sensor on the same board lets the cloud side run it as a thermostat.
Parts used in the switch
Listing titles copied exactly from the Taobao cart
| Part | Exact Taobao listing | Shop | Variant | Qty |
|---|---|---|---|---|
| ESP32 dev board | esp32开发板模块 CP2102 Type-C USB接口WIFI蓝牙无线模块 | 泽杰旗舰店 | Type-C, 30-pin, headers soldered · ¥19.98 | 1 |
| Servo | MG996R MG946R MG995 金属标准舵机180度360度数码机器人20KG舵机 | 深圳市轩特佳电子 | MG996R, 180° (not 360°), all-metal gears · ¥11.30 | 1 |
| BME280 sensor | BMP180 280 BME280 388AHT20高精度大气压强气压传感器模块高度计 | 欣薇电子企业店 | BME280-3.3V模块 (I2C) | 1 |
| Dupont wires F-F | 杜邦线 母对母 公对母 公对公40P彩色排线连接线10/15/30/20/40CM | 欣薇电子企业店 | 母对母, 20 cm, 40-pin strip | 1 |
| Dupont wires M-F | 彩色40p杜邦线排线公对公母对母公对母实验端子连接线10/20/30CM | 深圳新芯得电子商城 | 公对母, 20 cm, 40-pin strip | 1 |
| 5 V USB charger (board) | Already owned. Powers the ESP32 through its USB-C port. | |||
| 5 V supply (servo) | Already owned. A separate supply just for the servo, so its current spikes stay off the board. | |||
| USB-C data cable | Already owned. Several cables on hand were charge-only (marked UL2464 22AWG). A data cable is marked like 28AWG/1P + 24AWG/2C. | |||
⚠ The ESP32 listing says CP2102, but the board that arrived enumerates as an FT232R USB-serial bridge (chip ESP32-D0WD-V3 rev 3.1). It makes no difference to the build: any classic 30-pin ESP32 DevKit works, as long as it is not an S3 or C3.
⚠ The servo listing bundles MG996R, MG946R and MG995. The variant picked was the MG996R 180°.
Wiring schematic
Two 5 V supplies: one charger powers the board over USB-C, and a separate one powers the servo. Their grounds are joined at the board.
Servo
| Servo wire | ESP32 pin | Note |
|---|---|---|
| yellow (signal) | D13 | 50 Hz PWM, 500–2400 µs pulse |
| red (+5V) | servo supply + | Not connected to the board |
| brown (GND) | GND | |
| servo supply − | GND (a second pin) | Common ground. Without it the signal on D13 has no reference and the servo ignores it. |
BME280
| Sensor pin | ESP32 pin | Why |
|---|---|---|
| VCC | D18 | A GPIO, not 3V3. The firmware powers the sensor only after CSB is already high (see below). |
| GND | GND | |
| SDA | D21 | I2C data |
| SCL | D22 | I2C clock |
| CSB | D23 | Driven HIGH, which selects I2C mode |
| SDO | GND | Sets I2C address 0x76 |
- Connector order trap: the servo plug is
signal, +5V, GNDbut the board header next to D13 runsVIN, GND, D13. Use separate jumpers. Pushing the plug on as one block puts 5 V on the signal pin. - BME280 mode latch: the chip picks I2C or SPI from the CSB level at the instant it gets power. On the always-on 3V3 rail it powers up before any code runs, sees CSB low, locks into SPI and never answers on I2C. Powering it from a GPIO (it draws ~1 mA, a GPIO supplies ~20 mA) lets the firmware set CSB high first.
- Solder the sensor header. With the pins only pressed in, reads worked about one attempt in three, and it looked like a wiring fault.
- Why the servo has its own supply: the MG996R pulls big current spikes when it moves. On a laptop USB hub it tripped over-current protection and dropped the whole hub.
How the program works
Roughly. Firmware is Arduino C++ on the ESP32. The cloud side is one Cloudflare Worker plus one Durable Object.
phone UI / Siri shortcut
│ HTTPS (key-gated)
▼
Cloudflare Worker ──► Durable Object "switch" ← thermostat + run log live here
▲ │
board dials OUT │ │ pushes "on" / "off" / "set 69 10"
one WebSocket │ ▼
ESP32 ──PWM D13──► servo ──► rocker switch
▲
I2C D21/D22
│
BME280
The board sits on campus WiFi behind NAT, so nothing on the internet can reach it directly. It opens one outbound WebSocket to the Worker and keeps it open, and commands come back down that socket. No laptop or home server sits in the path.
Firmware loop
- Boot: power up the sensor in the right order (CSB high, then VCC), join WiFi with modem sleep off, open the WebSocket and send a ping every 20 s so the NAT keeps the connection alive. Switch state starts as
unknown, and the servo does not move on boot. - Every 15 s read temperature, humidity and pressure. A reading that is NaN or outside −50…90 °C is thrown away.
- Every 20 s send
env 26.10 58.2up the socket. - On a command: attach the servo and turn it to 69° (on) or 10° (off). If the arm is already resting at that angle, it first backs off to 40° so the move still presses the switch. It waits 600 ms, then detaches PWM, so the servo doesn't buzz, heat up or keep pushing on the switch.
- Whenever its state changes, the board sends
on/offback, so the cloud always knows what the board believes.
loop():
every 15s: read BME280
every 20s: ws.send("env <temp> <hum>")
on ws msg "on"/"off":
if arm already at target: servo.write(40); wait 350ms
servo.write(target ? 69 : 10); wait 600ms; servo.detach()
if state changed: ws.send(state)
Cloud side
- Thermostat ("cruise") runs on every reading the board sends, not on a timer, so it never acts on stale data. It keeps a band, 25–27.5 °C by default. The remote's set temperature is the middle of the band, and swing is how far the room may drift either way.
- AC on and room ≤ low edge → send
off - AC off and room ≥ high edge → send
on - After a reboot, state is unknown → drive the switch to whichever side the temperature calls for
- AC on and room ≤ low edge → send
- Remote UI: an LCD panel over
−/ power /+. Power toggles the thermostat, and ± moves the set temperature in 0.5° steps. Siri and Control Centre shortcuts call the same/configURL. - Offline board: commands are refused (503), not queued. A queued command once fired hours late and switched the AC on with nobody there.
- Run log: one row per on/off transition, plus one sensor row a minute kept for 7 days. These feed the usage totals, the charts and a "not cooling" check. If the room hasn't dropped 0.3 °C after 45 minutes of running (average of the first 6 minutes vs the last 6), the UI shows a warning.
- Security: anything that moves the switch needs the key, and a client IP gets 8 failed attempts per 15 minutes. Reading the temperature and state is public.
Socket messages
| Direction | Message | Meaning |
|---|---|---|
| board → cloud | on · off · unknown | What the board last did. Sent on every change and on every reconnect. |
| board → cloud | env 26.10 58.2 | Temperature °C, humidity % |
| cloud → board | on · off · toggle | Flip the switch |
| cloud → board | set 69 10 | Calibration angles (on, off), pushed live on every connect |
| cloud → board | test | Wiggle ±20° without pressing the switch |
Numbers
| Setting | Value |
|---|---|
| On / off / neutral angle | 69° / 10° / 40°, set by trial against the real switch |
| Servo pulse | 500–2400 µs at 50 Hz, PWM released after 600 ms |
| Sensor read / report | 15 s / 20 s |
| Tap → switch confirmed | ~0.5 s |
| Idle cost | Pings are answered by Cloudflare without waking the Durable Object, and storage writes stay well inside the free tier |
Software
- ESP32 Arduino core 3.3.11 ·
ESP32Servo·WebSockets(Markus Sattler) ·Adafruit BME280· ArduinoOTA for flashing over WiFi - Cloudflare Worker + SQLite-backed Durable Object, free plan
Lessons that cost the most time
- WiFi modem sleep made latency climb in a sawtooth and caused 30 s dropouts.
WiFi.setSleep(false)cut the worst case from 220 ms to 9 ms. - The board only knows what it commanded. If someone flips the switch by hand, the board doesn't see it. That is why every move goes through the neutral position first.