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
ECONNRESETduring 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.