Native Kotlin library for local control of Midea (and rebranded) WiFi air conditioners over the LAN — no cloud after a one-time key fetch. This is a Kotlin port of the msmart-ng.
Pure JVM (java.net sockets, javax.crypto), so it runs on plain JVM and on
Android (see Android). The only dependency is
kotlinx.serialization, used to
parse the cloud's JSON.
- Discovery of devices on the LAN (UDP broadcast).
- Cloud key fetch (NetHome Plus) to obtain a device's token/key once.
- Setup: discover → fetch key → verify → credentials.
- Local control: power, mode, temperature, fan, eco/turbo/swing, display toggle; reads full state. Persistent connection.
Calls are blocking — run them off the main thread (e.g. Dispatchers.IO).
// One-time setup (touches the cloud once). Uses a shared community account for
// the region by default; pass your own NetHome Plus account for reliability:
val credentials = Setup.run().first() // Region.US default
// val credentials = Setup.run(Region.DE).first() // another region
// val credentials = Setup.run("you@example.com", "pw").first() // your account
// All local from here — store the credentials and reuse:
val client = MideaClient(credentials)
val state = client.refresh()
println("${state.targetTemperature} ${OperationalMode.fromRaw(state.mode)}")
client.update { set ->
set.powerOn = true
set.targetTemperature = 22.0
set.mode = OperationalMode.COOL.raw
}Setup.run is the high-level path. The same steps are exposed individually if you
want to drive setup yourself — discover, fetch the key from the cloud, then connect:
val cloud = NetHomePlusCloud("you@example.com", "pw") // or NetHomePlusCloud.forRegion()
cloud.login()
val device = Discovery.discover().first() // a DiscoveredDevice on the LAN
// The cloud keys the token on the udpid; the byte order isn't discoverable, so try
// both and use whichever the cloud answers (see "Design notes").
val (token, key) = listOf(false, true).firstNotNullOf { bigEndian ->
runCatching { cloud.getToken(UDPID.compute(device.id, bigEndian)) }.getOrNull()
}
val client = MideaClient(
DeviceCredentials(device.name, device.id, device.ip, device.port, device.version, token, key),
)
println(OperationalMode.fromRaw(client.refresh().mode))Requires minSdk 26 (Android 8.0): the protocol timestamp uses java.time,
which on older API levels needs
core-library desugaring.
API 26 covers essentially all active devices, so this is rarely a real
constraint. Receiving UDP discovery replies may also require a
WifiManager.MulticastLock on some devices.
A freshly authenticated device drops or ignores queries sent in the first moment
after the handshake. We send one throwaway getState probe and proceed the
instant its reply begins arriving, bounded by a ~1.2s ceiling — a fast unit
answers in a fraction of a second. getState is idempotent, so the probe is
harmless.
Residual edge: if a device first answers slower than the ceiling, its probe reply is left unconsumed and a following relative toggle (the only non-idempotent, non-retried call) could misread it. The ceiling is sized so a healthy device always replies within it; the protocol has no request/reply correlation id to close this fully.
MideaClient owns one stateful connection, so interleaving calls would corrupt
the stream. It deliberately does not serialize internally: a blocking lock would
block a thread mid-round-trip (wrong for coroutine callers), and a suspending
Mutex would pull in a coroutines dependency. Instead it fails fast (an
AtomicBoolean guard that throws on concurrent entry); correct callers that
already serialize never trip it.
The cloud stores a device's token/key under a udpid derived from its device id.
The official app computed that udpid using a particular byte order of the id when
it registered the device, and that order varies across firmware/app versions.
Nothing in the device's discovery reply or the cloud API reports which order was
used, so it can't be computed or detected locally — the only signal is the cloud
itself. So setup computes both candidates and calls getToken for each; whichever
returns a token is correct. Hence Setup (and the manual example) try
little-endian, then big-endian.
Device ids arrive as raw bytes from UDP discovery (6 bytes, ≤ 2^48), never from JSON, and the cloud JSON carries only strings — so the JSON number representation never affects them.
src/test/resources/vectors.json was generated from the canonical Python
reference (msmart-ng); the tests assert
mideakt's framing, CRC, command encodings, and state parsing match it.