# Building a Mac menu bar app for my Panasonic AC: MQTT, a Wi-Fi mystery and shipping outside the App Store

I have Panasonic air conditioners at home that can only be controlled two ways: a remote that is never where I left it, or the MirAIe app on my phone. I spend most of my day on a Mac, so I wanted the AC one click away in the menu bar.

That turned into [Hawadaar](https://abdulghani.tech/hawadaar), a free, open-source macOS menu bar app for Panasonic MirAIe ACs. *Hawadaar* (ہوادار) is Urdu for "airy, well-ventilated". This post covers how it works, the Wi-Fi problem it helped me diagnose, and the parts of shipping a Mac app outside the App Store that took longer than the app itself.

![Hawadaar's panel open below its menu bar icon](https://abdulghani.tech/hawadaar/hero.png)

## How a MirAIe AC is actually controlled

My first assumption was that the AC exposes something on the local network that I could talk to directly. It doesn't. A MirAIe AC's Wi-Fi module keeps an outbound connection to Panasonic's cloud, and everything, including the phone app, goes through that cloud.

The community had already worked out the protocol (credit to [rkzofficial/miraie-ac](https://github.com/rkzofficial/miraie-ac) and [selvakk2k/miraie-ac-in](https://github.com/selvakk2k/miraie-ac-in)). It has two parts:

1. **REST for setup.** Sign in at `auth.miraie.in` with your MirAIe mobile number (or email) and password to get an access token, then fetch your homes and devices. Each device comes with an MQTT topic prefix.
2. **MQTT for everything live.** Connect to `mqtt.miraie.in:8883` over TLS, with the home ID as the username and the access token as the password. Then:
   - subscribe to `{topic}/status` for state changes
   - subscribe to `{topic}/connectionStatus` for online/offline events
   - publish commands to `{topic}/control`

Commands are tiny JSON documents. Every command carries the same three protocol fields plus whatever you want to change:

```json
{ "ki": 1, "cnt": "an", "sid": "1", "acvs": 3 }
```

That one points the vertical louver to position 3. `ps` is power, `actmp` the setpoint (as a string), `acmd` the mode, `acfs` the fan speed, and so on. The status messages use the same keys, so the app keeps one dictionary per AC and merges every status message into it.

There was one trap in the status data. On firmware 3.02 and later, room temperature (`rmtmp`) is packed as `"<fraction × 256>.<whole degrees>"`. A value of `"43.28"` means 28 + 43/256 ≈ 28.2 °C, not 43.28 °C. My Hall AC showed 43 °C until I noticed.

## A dependency-free MQTT client

I wanted the app small and auditable, so instead of pulling in an MQTT library I wrote the subset I needed: CONNECT, SUBSCRIBE, PUBLISH at QoS 0, PINGREQ and DISCONNECT, on top of Apple's Network framework, which handles TLS.

The encoding side is short. The only fiddly part of MQTT framing is the "remaining length", a variable-length integer:

```swift
static func packet(_ header: UInt8, _ body: Data) -> Data {
    var data = Data([header])
    var length = body.count
    repeat {
        var byte = UInt8(length % 128)
        length /= 128
        if length > 0 { byte |= 0x80 }
        data.append(byte)
    } while length > 0
    return data + body
}
```

The decoding side matters more, because TCP gives you a byte stream, not messages. A single read can contain half a packet, or three packets and a bit. The decoder keeps a buffer and only emits a packet once the whole frame has arrived. The unit test I trust most feeds the decoder one byte at a time:

```swift
var packets: [MQTTPacket.Incoming] = []
for byte in stream { packets += decoder.append(Data([byte])) }
#expect(packets == [.connAck(returnCode: 0), .publish(topic: "x", payload: Data("hi".utf8)), .pingResponse])
```

Two real-world details came out of testing on laptops:

- **Sleep kills connections silently.** After a MacBook wakes, the TCP connection can look alive while nothing comes through, so the app showed "Live" while commands went nowhere. The client now tracks when it last heard from the broker, and if a keep-alive period passes with no ping response, it drops the connection and reconnects. The app also signs in again and reloads every AC's status on wake.
- **Tokens expire.** When the broker refuses the connection (CONNACK codes 4 or 5), the app signs in again before reconnecting.

The result is under 1 MB with no third-party code, and the protocol layer (`HawadaarCore`) has its own unit tests, run on every push with GitHub Actions.

## The Wi-Fi mystery

Part of the motivation for all this was that one AC, the one in my bedroom, kept going "offline" in the MirAIe app. The Wi-Fi light on the indoor unit would switch off, and it would come back on its own a few minutes later.

I have a fairly standard home setup: a router plus three wired mesh access points, one per floor. I'd already tried what you'd expect. I pinned the AC to the access point in my room, forced it onto 2.4 GHz, tried the guest network and turned off mesh roaming for it. The access point's app rated the AC's signal "Strong" and the interference scan said "Perfect". The drops continued.

### Measuring instead of guessing

So I left a small monitor running overnight that pinged, every two seconds:
- the AC
- the router
- a public DNS server, to stand in for the internet
- Panasonic's MQTT broker

It logged only state changes. The overnight log said:

```text
01:16:00 ac DOWN (3 missed pings)
01:16:17 ac UP again (was down 17s)
01:17:07 ac DOWN (3 missed pings)
...
01:20:09 ac UP again (was down 2s)
```

That was five short drops in four minutes, then calm. The router, the internet and Panasonic's broker never dropped. That ruled out the ISP and the cloud: the problem was the wireless link between that one AC and the access point a foot away from it.

### The number that explained it

Then the MQTT status messages gave me something I hadn't asked for: each AC reports its own Wi-Fi signal strength, `rssi`.

| AC | Signal it reports |
|---|---|
| Office | −41 dBm |
| Hall | −42 dBm |
| **Bedroom** | **−83 to −88 dBm** |

At about −85 dBm, a Wi-Fi link is at the edge of usable, and that AC sits about a foot from its access point. A healthy radio at that distance should be in the −30s.

It also explained the contradiction with the access point's "Strong" rating. The access point measures how well **it** hears the AC, and its antennas are good. The RSSI the AC reports is how well **the AC** hears the access point. A transmitter that works and a receiver that barely does points to the AC's Wi-Fi module itself, a weak radio or a loose antenna connection, not to the network I'd spent evenings tuning. None of my router changes could have fixed it.

Hawadaar now shows that number in the panel footer, turns it orange below −75 dBm, and logs every online/offline event from the cloud to `~/Library/Logs/Hawadaar.log`. It's the evidence I'd want in hand before booking a service visit.

## Shipping a Mac app outside the App Store

Writing the app took less time than making it behave on other people's Macs. None of these problems show up while you're developing on your own machine.

### Gatekeeper and the first launch

Without a paid Apple Developer ID, an app downloaded in a browser is quarantined. On macOS 15 the old right-click → Open trick is gone: users see "Apple could not verify Hawadaar" with only *Done* or *Move to Bin*, and have to go to **System Settings → Privacy & Security → Open Anyway**. I can't fix that without notarization, so the README and the release notes walk through it step by step for each macOS version.

### "I opened it and nothing happened"

A menu bar app has no window and no Dock icon. On a MacBook with a notch and a full menu bar, macOS silently hides extra icons behind the notch. My first tester, me on my other MacBook, saw nothing at all after launching it.

The fix had two parts:
- **A first-launch window** that says where the app lives. It checks whether the status item is actually on screen, and if it isn't, explains the notch.
- **A fallback for reopening:** opening the app again while it's running shows the full controls in a normal window whenever the icon is hidden.

### `MenuBarExtra` looks different on newer macOS

I started with SwiftUI's `MenuBarExtra`, which is the obvious choice. On a newer macOS release it gained a translucent glass frame above my content, an empty strip I couldn't remove from SwiftUI, because it wasn't part of my view. I replaced it with an AppKit `NSStatusItem` and a borderless `NSPanel` that I position, size and dismiss myself. That's more code, but every macOS version now draws exactly the same pixels.

### Keychain prompts after every update

I sign builds with a self-signed certificate, hoping the Keychain would treat each new build as the same app. It doesn't: without a Developer ID, the Keychain ties access to the exact build, so after an update users approve one password prompt. It's a small cost, but worth knowing before you promise "seamless updates".

### One-click updates that don't trip Gatekeeper

I wanted updates to be one click: no re-downloading, unzipping and dragging, and especially no second trip through Privacy & Security. The updater:

1. **Downloads** the release zip from GitHub inside the app.
2. **Checks the SHA-256** against the digest GitHub publishes for that release asset.
3. **Unpacks it** and checks that it's a newer Hawadaar with the same bundle ID, **signed with the same certificate** as the running copy.
4. **Replaces the app in place** and relaunches.

Because the app downloads the file itself rather than a browser, the new copy isn't quarantined, so Gatekeeper doesn't step in.

Testing it the realistic way, on a copy I'd marked as browser-downloaded, exposed one more macOS behaviour: **App Translocation**. macOS runs freshly downloaded apps from a random read-only copy, so `Bundle.main.bundleURL` points to somewhere like `/private/var/folders/.../AppTranslocation/...`, not `/Applications`. Replacing *that* does nothing useful. The fix, the same one Sparkle uses, is to ask Security.framework for the original location:

```swift
guard running.path.contains("/AppTranslocation/"),
      let security = dlopen("/System/Library/Frameworks/Security.framework/Security", RTLD_NOW),
      let symbol = dlsym(security, "SecTranslocateCreateOriginalPathForURL") else { return running }
```

With that, the first real update, from 1.0.0 to 1.0.1, installed on my second MacBook with one click and no reinstall.

## Small things that made it feel finished

- **The airflow animation follows the real louver.** Streams sweep on Swing and change angle for positions L1–L5, driven by the `acvs` value the AC reports, so it updates when someone uses the remote.
- **Every AC on the account** sits behind a single switcher, because a house rarely has one AC.
- **The sign-in screen** tells you that Indian numbers don't need +91, and shows exactly which number or email it will use before you submit, so typos are caught before anything is sent to MirAIe.

## Takeaways

- **Look for the data the device already sends.** The single most useful diagnostic, the AC's own RSSI, was sitting in every status message.
- **Measure the whole path.** Monitoring the AC, the router, the internet and the cloud broker together turned "my AC keeps disconnecting" into "this one radio link fails while everything else stays up" in one night.
- **Distribution is an engineering problem.** Gatekeeper, App Translocation, Keychain access control, the notch and OS-specific window chrome shaped the app as much as the features did. Test on a second machine early.

Hawadaar is free and MIT-licensed. If you have a Panasonic MirAIe AC and a Mac, try it, and if you find a bug or want a feature (sleep mode and schedules are next), open an issue.

- **Download and details:** [abdulghani.tech/hawadaar](https://abdulghani.tech/hawadaar)
- **Source:** [github.com/abdulghanitech/hawadaar](https://github.com/abdulghanitech/hawadaar)

*Hawadaar is an independent project and isn't affiliated with Panasonic. Panasonic and MirAIe are trademarks of their respective owners.*

