Home Blog

A window AC without the app

A 12,000 BTU Midea window unit ships with an iOS app. The app works, and it is also a phone app for a thermostat, which means an account, a cloud round trip, and no history of what the room actually did overnight. The unit already speaks a documented protocol on port 6444 on my own network. A web page is a better fit.

This is that build: a Python backend holding one LAN connection to the unit, a browser UI pushed over server-sent events, and SQLite logging the sensors the app never showed me. After a one-time pairing there is no cloud involved.

The protocol, and the part you cannot do locally

Midea units come in protocol versions. V2 is plaintext. V3 is encrypted and will only accept commands from a client holding a token and a key.

Those are issued by Midea's cloud, to an account the appliance is bound to. There is no way to derive them locally. This is worth stating plainly because it is the first thing you go looking for: the community built the cloud detour because no local method exists, and if one did, nobody would have bothered.

Discovery itself needs no cloud. msmart-ng will identify a unit and tell you what it is:

protocol version: 3
  ip: 192.168.1.50
  port: 6444
  name: net_ac_111E
  device_type: 172

172 is 0xAC. The net_ac_… name is the same SSID the unit advertises when you put it in pairing mode.

The libraries ship with shared fallback accounts for fetching a token. Every one of them is rate-limited into uselessness and returns Code: 9999. Plan on using your own account.

The Apple sign-in dead end

Mine was signed in to the SmartHome app with Apple, which means there is no password, and the cloud API takes an account and a password. Nothing to type.

Two ways out. The first leaves the unit alone: find the relay address Apple handed the vendor (Settings → Apple ID → Sign in with Apple → the app, usually a …@privaterelay.appleid.com forwarder) and run a password reset against it. Apple forwards the mail to your real inbox, you set a password, and you now have credentials for an account the unit is already bound to.

The second always works, and is what the pairing button on the unit is actually for: register a fresh account with a plain email and password, hold the pairing button until the unit advertises its net_ac_… network, add it there, and pair against that. The new binding replaces the old one, so the app under your Apple ID will stop seeing it.

One library note. msmart-ng ships a working SmartHomeCloud class — it even carries an explicit workaround for that cloud rejecting getToken with a 3004 unless you also pass the appliance ID — but its CLI only ever wires up NetHomePlusCloud. If your unit is registered to the SmartHome app, drive the token fetch yourself:

cloud = SmartHomeCloud(region, account=account, password=password)
await cloud.login()
for endian in ("little", "big"):
    udpid = Security.udpid(dev.id.to_bytes(6, endian)).hex()
    token, key = await cloud.get_token(udpid, dev.id)
    try:
        await dev.authenticate(token, key)   # prove it before saving
        return token, key
    except AuthenticationError:
        continue

Both byte orders, because vendors disagree on how the udpid is derived, and verify against the actual unit before writing anything to disk. A token that "came back from the cloud" is not the same as a token that works.

One connection. Exactly one.

The single most useful thing to know about these units:

A Midea AC accepts one TCP connection. A second client gets ECONNRESET during authentication.

The symptom is credentials that worked sixty seconds ago suddenly failing, which sends you off auditing your key handling. It is not the key. Something else is holding the socket — another copy of your own service, or the vendor app on your phone.

pgrep -af 'uvicorn main:app'
ss -tnp | grep 6444

Three consequences for the design. All device I/O goes behind a single asyncio lock. Rapid UI input is debounced into one command, so tapping + five times sends one write rather than five. And when the server sweeps the subnet looking for a unit that moved, it excludes the address it is already talking to — otherwise the diagnostic opens and slams a socket on the very connection it is trying to diagnose. I wrote that bug and then watched it make the problem worse.

Session drops are normal, so stop reporting them

Even while being polled every 20 seconds, the unit closes its session roughly every two minutes. The library reconnects on the next request, so nothing is actually broken — but naively surfacing it means the UI flashes "Offline" every two minutes.

The fix is to ride out a few consecutive failures before believing it:

if self._fail_streak < _TRANSIENT_FAILURES:
    await asyncio.sleep(_TRANSIENT_RETRY)
    continue
self.connected = False    # only now is it worth telling anyone

Measured over eight minutes: about six resets underneath, and the UI reported connected for all 96 samples.

I also tried getting ahead of it by recycling the socket every 120 seconds. That is exactly backwards — the unit drops its session on its own schedule regardless, so a proactive recycle just doubles how often you re-authenticate. Default it off.

A simulator that lies is worse than no simulator

I built a mock unit so the UI could be developed without touching hardware. It let me verify the whole interface before the real unit was even paired, which was worth it. It also shipped me a bug.

The real device stores its capability flags in a CapabilityManager. My mock stored a plain Flag. Both support the obvious-looking test:

AC.Capability.ENERGY_STATS in dev._capabilities

except that on the real object it raises TypeError: argument of type 'CapabilityManager' is not a container or iterable. Every mock run passed. The first run against hardware crashed.

Two lessons, and the second is the one that matters. Use the public accessor — dev.capabilities_dict()["additional_capabilities"] is a plain list and works on both. But more importantly, a mock's job is to have the same shape as the thing it stands in for. Mine had the same behaviour for the calls I happened to make, which is not the same promise, and it converted a bug I would have caught in five seconds into one that surfaced in production.

The real unit had other opinions too. It reports fan speed as a raw 102, not an enum member — so a UI comparing against "auto" silently highlights nothing. It has no humidity sensor and no energy metering, so those tiles and that entire chart should not render. Reading capabilities at connect time and rendering only what the hardware claims is more code than hardcoding your own model, and it is the difference between a program and a program-shaped thing that works once.

Two front-end bugs worth writing down

An <svg> root only hit-tests where a child actually paints. I put pointermove on the chart's <svg> and the crosshair tooltip never fired, because the pointer was over empty plot area between the lines. Nothing is painted there, so nothing receives the event. The fix is a transparent capture layer:

<rect x="0" y="0" width="100%" height="100%" fill="none" pointer-events="all"/>

Verified by probing ten points across the plot with elementFromPoint — which, incidentally, only works for coordinates inside the viewport, so scroll the element into view first or you will get ten nulls and a wrong conclusion.

Do not snap a chart's domain out to round tick values. The obvious "nice ticks" implementation extends the domain to the outermost tick, which for data spanning 68–90 gives you an axis of 60–100 and throws away a third of the plot height. Pad the data range instead and place ticks inside it.

Related: pick the tick count from the label format's width. Adding seconds to the time format for short ranges made every label wide enough to collide with its neighbour.

Uvicorn will not shut down while a stream is open

Restarting the service took 90 seconds and ended in a SIGKILL, leaving the unit stuck in stop-sigterm.

The cause is an ordering detail: uvicorn drains in-flight requests before running lifespan shutdown. An SSE endpoint looping on while True is an in-flight request forever, so it holds the process open until systemd's stop timeout fires.

My first fix was to set an asyncio.Event during lifespan shutdown and have the stream watch for it. That does not work, for exactly the reason above — by the time lifespan shutdown runs, uvicorn has already been waiting on the stream for a minute and a half. What works is capping the drain:

uvicorn ... --timeout-graceful-shutdown 5

plus TimeoutStopSec=20 in the unit as a backstop. 90 seconds to 6.

Kernel modules and a pending reboot

Installing Tailscale on the host, tailscaled refused to start and crash-looped 156 times:

modprobe tun failed: Module tun not found in /lib/modules/6.19.11-zen1-1-zen
tun.ko found on disk, but not for current kernel:
  /lib/modules/7.2.6-zen2-1-zen/kernel/drivers/net/tun.ko.zst

Nothing to do with Tailscale. On Arch, pacman -Syu had upgraded the kernel six minutes after the machine booted, which removes the running kernel's module tree. Anything that needs to load a module it has not already loaded is now broken until reboot: tun, VirtualBox, ZFS, some Wi-Fi drivers.

Reboot. There is no workaround, because the module on disk is built for a kernel you are not running.

HTTPS on a phone, without a CA profile

Over plain HTTP, iOS labels the page "not secure" every time. A self-signed certificate is worse — you install a profile on the phone and manually trust it in Certificate Trust Settings.

Tailscale solves this in two commands, and gives you off-LAN access at the same time:

# admin console -> DNS -> Enable HTTPS
sudo tailscale serve --bg 8464

That publishes the local port at https://<host>.<tailnet>.ts.net with a real Let's Encrypt certificate, scoped to the tailnet rather than the public internet, renewed automatically. iOS trusts it out of the box. Add to Home Screen and it behaves like the app it replaced.

Do disable key expiry on the machine in the admin console. The default node key lasts 180 days, after which a headless box quietly drops off the tailnet.

Worth it?

The UI hides what the hardware does not have, so it is honest about a unit with no humidity sensor. It logs temperature to SQLite, which answers "what did the room do last night" — the question that started this. It works from a phone over a real certificate, on or off the network.

And it does not phone anyone. After pairing, the only traffic is between a server in the next room and an air conditioner in the window.

The other half of the answer is that most of the interesting bugs here had nothing to do with air conditioning: a mock that lied about its shape, an event model that only fires over painted pixels, a shutdown ordering assumption, and a kernel that had already moved on. That is the usual ratio.