CarrierBar: Putting My Thermostats in the Menu Bar

I wanted the temperature of my house next to the clock on my Mac, not inside an app on my phone. CarrierBar is a small, open source menu bar app that reads Carrier's private cloud API, stays live over a websocket, and survives an API that never quite agrees with itself.

CarrierBar: Putting My Thermostats in the Menu Bar

A thermostat on the hallway wall is a great piece of interface design, and almost nobody thinks of it that way. You never decide to check it. You walk past, the number is there, and you already know whether the house is comfortable.

Smart thermostats quietly took that away. My house has two Carrier units, and everything they know lives in the Carrier SmartHome app on my phone. The data got richer. The glance disappeared. Finding out whether the second unit is keeping up now means unlocking a phone, opening an app, and waiting for it to load.

So I put the glance back somewhere I already look all day. CarrierBar is a small macOS menu bar app that shows the current temperature of every Carrier unit on my account, right next to the clock. Click it and a panel opens with the details. It is free, open source under the MIT license, and the code is on GitHub.

What it shows

The menu bar item is a snowflake followed by one temperature per unit, something like 72°·75°. Like MyKeepass, it has no Dock icon and no window until you ask for one.

Clicking it opens a panel with a card for each unit. The room temperature sits up top in large type, with the target setpoint and humidity next to it. Below that come the mode, the fan speed, whether the unit is following its schedule or on hold, the outdoor temperature, how much of the filter is used, the state of the indoor and outdoor units, and the model and firmware. A badge says whether the unit is cooling, heating, idle, or offline, and a small green dot in the header means the data is live.

It works with both families of Carrier hardware, Infinity systems and Carrier Smart Thermostats. Each enabled zone becomes its own entry, sorted by name. If two systems share a default name like "HVAC", the app appends the last four digits of the serial number so you can tell them apart.

There is no public API, so I used the one the apps use

Carrier does not publish an API for homeowners. Its phone app and the my.carrier.com portal both talk to a private GraphQL backend, authenticated with tokens from Carrier's Okta server at sso.carrier.com. CarrierBar talks to that same backend.

I did not have to map it from scratch. Bren Dahl's carrier_api, an MIT licensed Python client that also powers the ha_carrier Home Assistant integration, had already worked out the GraphQL operations, the authentication flow, and the realtime protocol. CarrierBar follows its lead, in Swift, as a native Mac app.

The honest caveat belongs up front. An undocumented API can change or disappear any day, and nothing about this project is affiliated with or supported by Carrier. Use it with your own account only. I treat that risk as a design constraint rather than a footnote, and most of the interesting engineering in this app came out of it.

Signing in, three ways

Authentication turned out to be most of the work, because the obvious path is not always the one people can use.

Password. Your Carrier account email and password go once to the same sign in mutation the apps use. The password is never stored.

Web. CarrierBar opens my.carrier.com in its own window. You sign in there the way you normally would, and once a second the window checks the portal's localStorage for its session. When the session shows up, CarrierBar takes the tokens and closes the window by itself. Closing it right away matters. A portal page left open would keep renewing the same session CarrierBar now owns, and two clients taking turns with one refresh token is a good way to end up with one of them signed out. For the same reason, if the portal's token is within two minutes of expiring, CarrierBar waits for the portal to renew it first.

Token. If you are already signed in at my.carrier.com in Safari or Chrome, you can hand that session over directly. Open the browser's JavaScript console on that page and run:

copy(localStorage.getItem('AUTH_DATA--1'))

Then paste the result into CarrierBar. That entry holds both the access token and the refresh token, so the app can keep the session alive on its own. The paste field is forgiving on purpose. It also accepts a bare access token, a Bearer value, or a whole Authorization header, and it digs through nested JSON to find the tokens.

Pro tip: After pasting the session into CarrierBar, close the my.carrier.com tab without signing out. Signing out ends the session you just handed over, and leaving the tab open means the browser and CarrierBar take turns renewing the same session.

Whichever way you sign in, everything ends up in a single item in the login Keychain: the account email, the tokens, and the expiry. Access tokens last 30 minutes, and the app renews them through Carrier's Okta token endpoint about a minute before they expire. If Okta rotates the refresh token, the new one replaces the old one. Concurrent renewals collapse into a single request, so a burst of API calls never spends the same refresh token twice. And refresh tokens only ever go to sso.carrier.com. If a pasted token names a different issuer, the app refuses it rather than send a refresh token somewhere it should not go.

Live, with safety nets underneath

A temperature in the menu bar is only useful if it is current. Polling every few seconds would be wasteful and would still feel slow, so CarrierBar keeps Carrier's realtime websocket open, the same one the apps use, and stacks fallbacks underneath it.

Push. Status messages carrying temperature, humidity, setpoints, or the cooling and heating state are merged into the current data the moment they arrive. Only the fields present in the message change.

Debounced refetch. Any other message, a configuration change for example, triggers a full fetch. Bursts are coalesced, so ten messages in four seconds cost one request.

Poll. A full fetch runs every 5 minutes as a safety net, and every minute while the websocket is down. Opening the panel also refreshes anything older than a minute.

Recovery. A dropped connection reconnects with exponential backoff, capped at five minutes. Waking the Mac from sleep reconnects and refreshes, after a short pause to let the network come back.

To watch it happen, filter Console.app by the subsystem com.cetorres.CarrierBar with debug messages on. The realtime category logs every websocket message, so you can see exactly what Carrier pushes when you change something in the phone app.

Decode like the API will surprise you

This is the lesson I would carry to any undocumented API. Carrier's responses are loose with types. The same kind of field can arrive as the string "74", the number 74, a boolean, "on" or "off", or the literal string "None". A strict Codable model treats every one of those surprises as a fatal error, and one odd value takes down the whole response.

So every scalar in CarrierBar goes through one tolerant type:

enum JSONScalar: Decodable, Hashable, Sendable {
    case string(String)
    case number(Double)
    case bool(Bool)
    case null

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if container.decodeNil() {
            self = .null
        } else if let value = try? container.decode(Bool.self) {
            self = .bool(value)
        } else if let value = try? container.decode(Double.self) {
            self = .number(value)
        } else if let value = try? container.decode(String.self) {
            self = .string(value)
        } else {
            // An object or array where a scalar was expected. Ignore it instead of failing.
            self = .null
        }
    }
}

Typed accessors do the rest. double reads "74" and 74 the same way. bool understands "on", "yes", and "1". string treats "None" and empty strings as missing. One odd value becomes one missing detail on one card instead of a blank panel.

The same humility runs through the rest of the model. Infinity systems and Smart Thermostats return different shapes, so both are mapped into one Thermostat type before the interface ever sees them. Infinity reports states like "active_cool", Smart Thermostats say "Cool Stage 1", and both become the same Cooling badge. Infinity also reports the fan as "off" when it only runs with heating or cooling, and the panel shows that as Auto, because that is what it means. Every fetch runs both the Infinity query and the Smart Thermostat query, and if one fails while the other succeeds, you still get your thermostats.

Small app, tight boundaries

CarrierBar is about 2,500 lines of Swift across 21 files. The app is a SwiftUI MenuBarExtra with a window style panel, an @Observable store for state, and an actor for the API client, so token renewal and requests never step on each other.

It runs sandboxed, and its only entitlement is outgoing network access. Passwords are never stored. Tokens never touch files or UserDefaults, and the logs never contain passwords or tokens. The Okta client ID in the source is the public identifier Carrier's own apps use. It is not a secret, and there is no client secret to leak.

There is also a demo mode. The CarrierBar Demo scheme launches with two invented units, makes no network requests, and never touches your saved session. It is how the screenshots get made, and the quickest way to try the interface without a Carrier account.

Try it

CarrierBar is at version 0.1.0 and there is no prebuilt download, so you build it yourself. You need macOS 14 or later, Xcode 16 or later, and the account you use in the Carrier SmartHome app.

git clone https://github.com/cetorres/carrier-ac-control.git
open carrier-ac-control/CarrierBar.xcodeproj

Press Run, click the snowflake in the menu bar, and sign in. After the first fetch, your temperatures appear next to it.

The roadmap is short and specific. Controls come first: setpoint, mode, fan, and hold and resume, through the same mutations the Carrier apps use. After that, a short name or icon next to each temperature, a choice of which unit appears in the menu bar, and launch at login.

Why it only reads, for now

I started this project wanting to control my AC from the menu bar. Version 0.1.0 only reads, and I think that is the right order.

Reading an undocumented API wrong costs you a stale number in the menu bar. Writing to it wrong costs you a warm house in August. The read path is where I learned how Carrier's API actually behaves: which fields lie about their types, how sessions renew, what the websocket pushes and when. That knowledge is exactly what the write path has to stand on.

The thermostat on the hallway wall never asked for my attention. It told me the number when I walked by. That is the whole job of a thermostat display, and it is the whole job of CarrierBar today.