Raspberry Pi airplane hangar controller. Started as a one-relay remote for the airplane's engine-block oil-pan heater; now also runs an exhaust fan and a Durastar/Midea mini-split HVAC over the LAN — all from a single Pi Zero W on hangar WiFi (with LTE backup).
The web UI shows current state of all three devices, a 7d/30d/monthly temperature chart with colored bands per device, and a one-shot scheduler that can drive any of them.
Links provided for your convenience, but buy from wherever you prefer.
- Raspberry Pi Zero W
- You'll need a microSD card if you don't have one. 4GB+ is enough.
- SIM7600 LTE modem HAT for Pi also available on Amazon
- You'll need a SIM card. The docs say nano but the unit I had uses a mini SIM slot. I used a Google Fi SIM since it only costs data on my existing plan.
- Skip this if you already have reliable WiFi at the hangar.
- Digital Loggers IoT relay
- Connect
-to GND on the Pi, and+to an unused GPIO pin. - Two of these for the engine-block heater (GPIO 17) and exhaust fan (GPIO 27).
- Connect
- (optional) DS18B20 temperature probe — displays temperature on the control page and drives fan auto-mode
- You'll also need a 4.7kΩ resistor between the data and power lines.
- (optional) Midea US-OSK105 WiFi USB dongle (~$30 on Amazon as ASIN B0GVSPFK1P) — required only if you want to control a Durastar or other Midea-OEM mini-split. See Hangar HVAC below.
Install Raspberry Pi OS Lite and configure the following.
Add to /boot/config.txt:
# https://forums.raspberrypi.com/viewtopic.php?f=117&t=208748
# set GPIO pin 17 as output, default low (heater relay)
gpio=17=op,dl
# exhaust fan relay — must match FAN_GPIO_PIN in config.py
gpio=27=op,dl
# (optional) enable 1-wire for DS18B20 temp probe
dtoverlay=w1-gpio
Add to /etc/rc.local (before exit 0):
echo "17" > /sys/class/gpio/export
echo "27" > /sys/class/gpio/export
Get your LTE modem running. The manufacturer's documentation is detailed but may require some trial and error with APN settings.
Install Apache and add www-data to the gpio group so the web UI can toggle the relay:
sudo apt install apache2
sudo usermod -a -G gpio www-dataEnable CGI and mod_wsgi:
sudo apt install python3-jinja2 libapache2-mod-wsgi-py3
sudo a2enmod cgi wsgiCreate the site config for mod_wsgi (keeps Python alive between requests for fast page loads):
sudo tee /etc/apache2/conf-available/heater.conf << 'EOF'
WSGIDaemonProcess heater python-path=/usr/lib/cgi-bin/remote-switch processes=1 threads=2 display-name=heater
WSGIScriptAlias /cgi-bin/remote-switch/switch.py /usr/lib/cgi-bin/remote-switch/switch.py
<Directory /usr/lib/cgi-bin/remote-switch>
WSGIProcessGroup heater
WSGIApplicationGroup %{GLOBAL}
Require all granted
</Directory>
EOF
sudo a2enconf heater
sudo systemctl restart apache2Clone the repository into your cgi-bin directory:
sudo git clone https://github.com/leithl/remote-switch.git /usr/lib/cgi-bin/remote-switchFuture updates are then just sudo git pull from that directory.
Edit /usr/lib/cgi-bin/remote-switch/config.py for hardware settings:
GPIO_PIN = "17" # GPIO pin connected to your relay
ENABLE_TEMP = True # set to False to disable all temperature featuresThe logger writes to SQLite on disk at /var/lib/heater/heater.db. The directory needs to be writable by both root (cron) and www-data (Apache):
sudo mkdir -p /var/lib/heater
sudo chown root:www-data /var/lib/heater
sudo chmod 2775 /var/lib/heaterThe database file is created automatically on the first cron run and its permissions are set correctly by the logger — no manual step needed.
Add these entries to root's crontab (sudo crontab -e):
* * * * * /usr/lib/cgi-bin/remote-switch/log_temp.py
0 0 * * 0 /usr/lib/cgi-bin/remote-switch/log_temp.py flush
0 0 1 * * /usr/lib/cgi-bin/remote-switch/log_temp.py rollup
What each job does:
- Every minute — reads heater state, temperature (if probe connected), and ambient temperature (if configured); executes any due schedules; writes one row to
/run/heater.db(RAM, tmpfs — no SD card write) - Weekly (Sunday midnight) — flushes RAM database to
/var/lib/heater/heater.dbon disk - Monthly (1st midnight) — pre-computes the previous month's chart data and stats into the
monthly_cachetable so past months load instantly; optionally emails a summary
On an unplanned power loss, data since the last weekly flush may be lost. For commanded reboots and shutdowns, see step 8 below.
Install the included systemd unit so that any commanded reboot or shutdown flushes the RAM database to disk first:
sudo cp /usr/lib/cgi-bin/remote-switch/heater-flush.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now heater-flush.serviceThe service does nothing at boot. On any reboot, shutdown, or systemctl stop it runs log_temp.py flush, copying RAM readings to the disk database before the filesystem unmounts. On an unplanned power loss the RAM contents are still lost (acceptable — the disk database always has data up to the last flush).
Heater schedules are unaffected by reboots regardless — they are stored on disk and the every-minute cron job catches up any missed schedules on the first tick after boot.
Install and configure a firewall:
sudo apt install ufw
sudo ufw allow ssh
sudo ufw allow http
sudo ufw enableInstall OpenVPN or WireGuard to connect to an existing private network.
The chart can display outdoor ambient temperature as a second line, fetched from Open-Meteo (free, no API key). Requires ENABLE_TEMP = True in config.py.
Create .env in the same directory as the scripts (e.g. /usr/lib/cgi-bin/remote-switch/.env):
Option A — airport ICAO code (recommended for hangar use):
LOCATION=KLMO
On the first cron run, the airport is geocoded to lat/lon via the OurAirports public dataset, and LATITUDE= / LONGITUDE= are automatically appended to .env. Geocoding is skipped on all subsequent runs.
Option B — direct coordinates:
LATITUDE=45.5051
LONGITUDE=-122.6750
The ambient temp is fetched every 15 minutes (cached in RAM between fetches) to minimise LTE data usage — ~96 API calls/day. If .env is absent or the fetch fails, the chart continues to work normally without the ambient line.
Add to .env:
NOTIFY_EMAIL=you@example.com
Requires msmtp to be installed and configured. The monthly rollup cron job sends a summary with temperature stats and heater runtime.
The web UI includes a one-shot scheduler that can act on the engine-block heater (turn on/off), the exhaust fan (turn on, turn off, or set back to auto), or the hangar HVAC (mode + target temp + fan + turbo, including a one-click Freeze Prevention preset). Schedules are stored in the database and executed by the every-minute cron job — no additional setup needed. Schedules survive reboots.
Fan schedules set the fan's mode rather than pulsing the relay, so "Turn ON" holds the fan on until another schedule or manual action changes it; "Set to Auto" hands control back to the temperature-threshold auto logic (handy for "force off overnight, back to auto in the morning").
The Midea WiFi dongle's pairing app (NetHome Plus) requires a WPA2-protected SSID. Hangar WiFi is often open or guest-only, which the app refuses to pair against. Skip this section if your hangar WiFi already has a password.
The fix is to run the Pi as a single-radio AP+STA bridge: it stays a client on the open hangar WiFi (wlan0) while broadcasting its own WPA2-protected SSID (uap0) on the same chip. The Midea dongle pairs to the Pi's SSID, NATs out through the hangar WiFi for the one-time cloud handshake, and lives on the Pi's AP subnet permanently. The Pi reaches the dongle directly across the AP subnet, so setup_hvac.py and msmart-ng work without any further routing.
sudo AP_PASS=ChangeMeStrong AP_CHAN=<channel> /usr/lib/cgi-bin/remote-switch/scripts/setup-wifi-bridge.sh
sudo rebootAP_CHAN must match the hangar WiFi's current channel — single-radio AP+STA shares one channel, and hostapd will refuse to start on a mismatch. Find it once with:
iw dev wlan0 link | grep freq
# 2412->1, 2437->6, 2462->11, etc. (5 MHz steps from 2412)Optional overrides via environment variables (defaults shown):
AP_SSID=hvac-pairAP_NET=192.168.50(Pi gets.1, dongle DHCPs.50–.150)
- On your phone, join
hvac-pair(the Pi's AP). Open NetHome Plus and pair the dongle, pointing it athvac-pair. - Find the dongle's IP on the Pi's AP subnet:
sudo cat /var/lib/misc/dnsmasq.leases
- Run
setup_hvac.pyper the Hangar HVAC section below.
The dongle stays on hvac-pair forever after this. The Midea cloud is only needed during pairing; once the local token+key are written to .env, you can firewall the dongle's outbound internet without losing functionality.
The BCM43438's AP+STA mode is functional but not bulletproof — uap0 can occasionally wedge after days/weeks and need sudo systemctl restart hostapd. For one low-traffic client (the Midea dongle) this is rarely a problem. If it bites you often, plug in a USB WiFi dongle (TP-Link TL-WN725N is small and well-supported on hostapd) and rerun the setup script with wlan1 for one of the roles — moving AP and STA onto separate radios eliminates the time-slicing and channel-sharing constraints entirely.
hostapdfails with "Could not set channel".wlan0is on a different channel thanAP_CHAN. Check current channel withiw dev wlan0 link | grep freq, editchannel=in/etc/hostapd/hostapd.conf,sudo systemctl restart hostapd. If the hangar router roams channels, pin it in the router's admin page.uap0doesn't appear after reboot. Checkjournalctl -u uap0— usually meanswlan0wasn't ready yet.sudo systemctl restart uap0 hostapd dnsmasqand it should come up.- Midea dongle pairs but the Pi can't reach it. Confirm the dongle got a lease (
/var/lib/misc/dnsmasq.leases) and that you canping <dongle_ip>from the Pi. If pings fail, hostapd is up but the dongle didn't actually associate — re-run the NetHome Plus pairing. - Internet works on the Pi but not from the dongle. NAT rule didn't persist.
sudo iptables -t nat -L POSTROUTING -vshould show a MASQUERADE rule onwlan0; if missing, re-run the setup script.
The web UI can also control a hangar Durastar/Midea mini-split (and any other Midea-OEM unit — Pioneer, MrCool, Senville, Comfee, etc.) over the LAN via the Midea WiFi dongle. This is independent of the engine-block heater described above — the heater stays on its own GPIO relay; the HVAC is reached over the LAN through the msmart-ng Python library.
- A small WiFi dongle plugs into a USB-shaped port inside the indoor unit's front panel and bridges Midea's serial protocol to a TCP service on port 6444 of the dongle's LAN IP.
- After a one-time pairing through Midea's cloud, the Pi extracts a local
token+keyand from then on talks to the dongle directly on the LAN — no cloud roundtrip per command, and you can firewall the dongle off the internet. - The web UI's HVAC card lets you set power, mode (Heat / Cool / Auto / Dry / Fan), target temperature in °F, fan speed, and a Turbo checkbox (the IR remote's "blast max output" function — on this Durastar it stays on until you turn it off). A one-click "Freeze Prevention preset" button sends Heat / 60°F / Low fan — useful for "keep the hangar from freezing" scenarios.
- The scheduler accepts HVAC actions alongside heater actions; same
execute_epoch <= nowcron-driven dispatch. - The dongle is polled at most once every 30 seconds (cached in
/run/heater-hvac.json); page loads never block on the network. If the dongle is unreachable, the UI shows the last-known state with a "stale Xs" badge instead of erroring. - HVAC activity is logged into
readings.ac_stateevery minute and rendered on the chart as a purple band, so you can see HVAC + heater + fan + ambient temperature on one timeline.
Midea US-OSK105 WiFi USB dongle (~$30 on Amazon: ASIN B0GVSPFK1P). The Durastar-branded DRWIFIADPT1 is the same hardware behind a contractor-only Ferguson SKU — buy the generic Midea version and skip the wait.
- Plug the dongle into the USB-shaped port behind the indoor unit's front panel (the snap-off filter cover; no electrical work).
- Pair it once via the NetHome Plus phone app — NOT SmartHome / MSmartHome. Their
get_tokencloud endpoint is currently broken (see msmart-ng issue #201). If you registered through SmartHome, re-register via NetHome Plus before continuing. - Install msmart-ng on the Pi (Pi OS Lite ships without pip by default):
The
sudo apt install python3-pip sudo pip install msmart-ng --break-system-packages
--break-system-packagesflag is needed on Bookworm and later (PEP 668). For this project's deployment model (system Python under mod_wsgi + root cron) it's the pragmatic choice — a venv would require reconfiguring the WSGI daemon and cron paths. - Run the setup helper — it prompts for your NetHome Plus credentials, then in a single round-trip discovers the dongle on the LAN and authenticates it via Midea's cloud (returning the local
token+key). The fourHVAC_*keys are then written to.envand the script verifies with a live refresh:sudo python3 /usr/lib/cgi-bin/remote-switch/setup_hvac.py
- Reload the web UI — the "Hangar HVAC" card replaces the "Not configured" placeholder, and HVAC becomes a device option in the scheduler.
mod_wsgi auto-reloads on .env changes the next time switch.py is touched; if the new card doesn't appear immediately, sudo systemctl reload apache2 (or git pull to bump the file mtime) forces it.
After pairing, four keys are added — don't edit by hand, use setup_hvac.py to refresh:
HVAC_DONGLE_IP=192.168.1.50
HVAC_DEVICE_ID=123456789012345
HVAC_TOKEN=<long hex string>
HVAC_KEY=<long hex string>
The unit's real Freeze Protection flag — the same feature the IR remote drives. When active the indoor unit displays "FP" and the firmware holds a minimum heat output (~8°C / 46°F) internally; you don't need to (and can't) set a target temp or fan speed. Picking any other mode in the web UI exits the flag automatically. Ideal for "keep the hangar above freezing all winter without thinking about it."
If someone uses the physical IR remote while you're away, the dongle's reported state diverges from what the web UI last commanded. When this happens, the HVAC card surfaces both the Reported state (what the unit is doing now) and Last commanded (what was last sent from the web), so you can see at a glance that the physical remote was used.
setup_hvac.pyfinds no devices. Check the dongle is powered (LED inside the indoor unit), paired via NetHome Plus, and on the same subnet as the Pi (hangar WiFi often runs an isolated guest network — make sure the Pi and the dongle are on the same one).- Setup fails at "Discovery / cloud auth failed". Most often a SmartHome vs NetHome Plus account mismatch — re-register via NetHome Plus and retry. If that's not it, double-check the password (case-sensitive) and that you can sign in to the NetHome Plus app on the phone with the same credentials.
ImportError: cannot import name '...' from 'msmart...'. msmart-ng's API has shifted between releases. The wrappers in this repo were verified againstmsmart-ng==2025.12.0. If you're on a much newer version and an import name has moved, see CLAUDE.md ("msmart-ng API surface") for the symbols to grep and pin a working version withsudo pip install msmart-ng==2025.12.0.- HVAC card shows "stale Xs ago". The dongle isn't replying. Check the indoor unit has power, the dongle hasn't fallen out, and the LAN IP hasn't changed (DHCP). If the IP changed, re-run
setup_hvac.pyto refresh.env. - HVAC card shows "Not configured" after pairing. Verify all four
HVAC_*keys are present and non-empty in.env.
The 3.5" IPS dashboard runs 24/7. To save LED-backlight hours (and heat), an optional DFRobot C4001 / SEN0610 24 GHz mmWave sensor turns the backlight off when the hangar's empty and back on the moment it senses motion. IPS panels have no burn-in, so this is purely backlight longevity. Off by default — the display behaves normally until you enable it.
Step-by-step wiring + install checklist:
docs/motion-wake-install.html(open in a browser — interactive, progress saved locally).
- C4001 mmWave sensor (~$13, DFRobot SEN0610): 8 m presence / 12 m motion, 100°×80° beam, I²C + UART, −40…85 °C. The 12 m variant — not the 25 m SEN0609.
- Wire it to the same I²C bus the touch chip uses (no extra GPIO):
VCC→3.3V, GND→GND, SDA→GPIO2, SCL→GPIO3. Its address0x2Adoesn't clash with the touch chip's0x38. - Backlight: move the panel's LED pin from VCC to GPIO18. If that pin draws more than ~16 mA, drive it through a small N-channel MOSFET (gate←GPIO18, drain←LED, source←GND) rather than directly.
- Install the sensor library's Python deps on the Pi:
sudo apt-get install -y python3-serial python3-smbus
DFRobot_C4001.pyis vendored in this repo (from DFRobot/DFRobot_C4001, MIT) — no separate clone needed. It importsserial(pyserial) andsmbusat the top even on the I2C path, so both must be present.gpiozero(for the backlight) ships with Raspberry Pi OS. - Confirm both chips are on the bus:
i2cdetect -y 1→ shows2aand38. - Aim and tune:
python3 display_loop.py --presence-testprints detection/range for 30 s. If it picks up near-field clutter or fires too eagerly (common in a metal hangar), dial it in with the opt-inPRESENCE_RANGE_*/PRESENCE_TRIG_SENS/PRESENCE_FRETTINGknobs in.env(see.env.example); theC4001 ready (… tuning: …)line confirms what took effect. - Enable in
.env, then restart the display service:PRESENCE_ENABLED=1 sudo systemctl restart display
- Backlight comes on the instant motion is sensed and off after
IDLE_TIMEOUT_SECS(default 120 s) with no motion and no touch. The timer is rolling — every detection resets it, so it stays on the whole time you're moving around and only counts down from the last time you were seen. - Tap-to-wake: tapping a dark screen just wakes it; tapping a lit screen toggles the heater as usual.
- Fail-safe: if the sensor is unwired, fails, or the library is missing, the backlight stays on — the feature can never leave the screen dark. All knobs (mode, timeout, fade, GPIO) are documented in
.env.example.
To minimise SD card writes on the Raspberry Pi, all per-minute data is written to a SQLite database in RAM (/run/heater.db, on tmpfs), not to the SD card. This file is flushed to disk weekly. The web UI reads from both the RAM and disk databases via SQLite's ATTACH so no data is ever lost between flushes.
/run/heater.db ← RAM (tmpfs). Volatile. Written every minute.
/var/lib/heater/heater.db ← Disk (SD card). Written weekly (flush) + monthly (rollup).
/run/heater-ambient.tmp ← Ambient temp cache (15-min TTL, ~50 bytes).
/run/heater-hvac.json ← Cached HVAC dongle state (30-sec TTL, ~400 bytes). Optional.