Reverse Engineering the Resideo Thermostat API: Full Home Assistant Control Without a Developer Key

The Honeywell Lyric T6 Pro WiFi (TH6320WF2003) is a perfectly good thermostat with a perfectly broken Home Assistant integration story. The official HA integration requires a developer API key from Resideo. The developer program has been effectively dead for years — applications sit in “pending review” indefinitely, and the few developers who got keys report chronic API outages.

I wanted my thermostat in Home Assistant with full bidirectional control — temperature, mode, fan, schedules. No developer key available. No working community integration. So I intercepted the Resideo iOS app’s traffic and built my own.

This post covers the complete reverse engineering process: the OAuth2 authentication flow with PKCE, the device and control API endpoints, and a Python integration that gives you a full HA climate entity with all the controls. The auth complexity is a significant step up from my earlier Govee pool thermometer project — Resideo uses enterprise-grade Azure AD B2C instead of a simple Bearer token.

Why Nothing Off-the-Shelf Works

The Official Lyric Integration

Home Assistant has a built-in Honeywell Lyric integration, but it requires an API key from developer.honeywellhome.com. The developer portal accepts applications. It does not process them. Mine has been pending since I submitted it.

The TCC Integration

The Honeywell TCC integration works with older thermostats that use the Total Connect Comfort portal at mytotalconnectcomfort.com. The T6 Pro WiFi uses the newer Resideo/Lyric platform at myid.resideo.com. Wrong portal, wrong API, no dice.

Community Integrations

Several HACS integrations exist for Honeywell/Resideo thermostats. Every one of them either requires the same unobtainable developer key or targets the older TCC platform. Dead ends across the board.

The MITM Approach

Same playbook as the Govee hack: intercept the app’s HTTPS traffic with Proxyman on macOS and watch what API calls the Resideo app makes. My device, my phone, my network.

Setting Up Proxyman

  1. Install: brew install --cask proxyman
  2. On iPhone: set WiFi proxy to the Mac’s IP, port 9090
  3. Install the Proxyman CA certificate on the iPhone and enable full trust
  4. Add SSL Proxying rules for lyric.alarmnet.com and lyricprod.b2clogin.com

If you did the Govee hack, you already have steps 1-3 done. Just add the new domains.

Discovering the Architecture

This is where the Resideo integration diverges sharply from Govee. The Govee API used a simple Bearer JWT in the header. The Resideo app uses Azure AD B2C with OAuth2 and PKCE — the full enterprise authentication stack.

The authentication involves:

  1. An Azure AD B2C tenant at lyricprod.b2clogin.com
  2. OAuth2 Authorization Code flow with PKCE (Proof Key for Code Exchange)
  3. A custom B2C policy called B2C_1A_SignIn_Mob_HH
  4. A token exchange that produces a 15-minute access token and a 90-day refresh token

This is what Proxyman showed me, step by step.

Step 1: The OAuth Login Flow

1a. Authorization Request

The app opens a webview to begin the login:

GET https://lyricprod.b2clogin.com/lyricprod.onmicrosoft.com/
    B2C_1A_SignIn_Mob_HH/oauth2/v2.0/authorize

Parameters:
  client_id:             d7baddb4-d9f3-4575-af28-f1b04a7883f2
  response_type:         code
  redirect_uri:          com.honeywell.acs.lyric.enterprise://oauth2redirect
  scope:                 https://lyricprod.onmicrosoft.com/CHILAPIService/
                         user_impersonation offline_access
  code_challenge:        <S256 PKCE challenge>
  code_challenge_method: S256
  state:                 <random>
  nonce:                 <random>

The code_challenge is the PKCE mechanism — the app generates a random code_verifier, hashes it with SHA-256, and sends the hash. The server will later verify that the token request has the matching verifier. This prevents authorization code interception attacks, which is good security engineering but makes automation harder.

1b. Credential Submission

The webview renders a login form. When you submit your email and password:

POST https://lyricprod.b2clogin.com/.../SelfAsserted

Form data:
  request_type: RESPONSE
  signInName:   your_email@example.com
  password:     your_password

Headers:
  X-CSRF-TOKEN:   <extracted from login page>
  X-Requested-With: XMLHttpRequest

The CSRF token comes from the HTML of the login page. You need to parse it out of the page source before submitting credentials.

1c. Authorization Code

After successful login, the server redirects to the app’s custom URL scheme:

GET .../api/CombinedSigninAndSignup/confirmed

Response: 302 redirect to
  com.honeywell.acs.lyric.enterprise://oauth2redirect?code=<auth_code>&state=<state>

The authorization code in that redirect is what we need for the token exchange.

1d. Token Exchange

POST https://lyricprod.b2clogin.com/.../oauth2/v2.0/token

Body:
  grant_type:    authorization_code
  client_id:     d7baddb4-d9f3-4575-af28-f1b04a7883f2
  code:          <auth_code from redirect>
  redirect_uri:  com.honeywell.acs.lyric.enterprise://oauth2redirect
  scope:         https://lyricprod.onmicrosoft.com/CHILAPIService/
                 user_impersonation offline_access
  code_verifier: <the original PKCE verifier>

Response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "eyJraWQiOiJjcGltY29yZV8w...",
  "expires_in": 900,
  "refresh_token_expires_in": 7776000
}

Key numbers: 15-minute access token, 90-day refresh token. The refresh token is the prize — as long as we renew it before expiry, we never need to redo the full login. The script handles renewal automatically, so the integration runs indefinitely.

Step 2: The Device API

With a valid access token, the device data endpoint returns everything:

GET https://lyric.alarmnet.com/api/v6/locations/5407233/devices

Headers:
  Authorization:             Bearer <access_token>
  User-Agent:                Lyric/6.19.1.0 (iPhone; iOS 26.3.1; Scale/3.00)
  Ocp-Apim-Subscription-Key: d146f1f6f74a40f2812f4ca204bce3d2
  AppVer:                    6.19.1.0
  Content-Type:              application/json; charset=utf-8

The Ocp-Apim-Subscription-Key is an Azure API Management gateway key. It’s embedded in the app binary, shared across all users. Without it, requests get rejected at the gateway before reaching the actual API.

Response (trimmed to the useful fields):

[{
  "deviceID": "LCC-48A2E61BB64F",
  "deviceOsVersion": "TH6320WF2003",
  "name": "Thermostat",
  "isAlive": true,
  "thermostat": {
    "units": "Fahrenheit",
    "indoorTemperature": 77.0,
    "outdoorTemperature": 83.0,
    "allowedModes": ["EmergencyHeat", "Heat", "Off", "Cool"],
    "changeableValues": {
      "mode": "Cool",
      "heatSetpoint": 70.0,
      "coolSetpoint": 77.0,
      "thermostatSetpointStatus": "NoHold",
      "nextPeriodTime": "21:00:00"
    },
    "operationStatus": {
      "mode": "EquipmentOff",
      "fanRequest": false
    }
  },
  "displayedOutdoorHumidity": 69.0,
  "currentSchedulePeriod": {
    "day": "Monday",
    "period": "Away"
  },
  "settings": {
    "fan": {
      "allowedModes": ["On", "Auto", "Circulate"],
      "changeableValues": { "mode": "Circulate" }
    }
  }
}]

Indoor temperature, outdoor temperature, outdoor humidity, mode, setpoints, fan mode, schedule info, hold status — all in one clean JSON response. The data quality is excellent compared to Govee’s nested-JSON-string-inside-JSON approach.

One detail worth noting: my thermostat reports allowedModes as Heat, Cool, Off, and EmergencyHeat — no Auto mode. That’s because my HVAC system is a heat pump without a dual-setpoint configuration. The allowed modes vary by thermostat model and wiring.

Step 3: The Control Endpoints

I changed settings in the Resideo app and captured the corresponding API calls.

Mode Changes

PUT https://lyric.alarmnet.com/api/locations/5407233/devices/
    LCC-48A2E61BB64F/thermostat/Mode

Body: { "ThermostatMode": "Cool" }

Valid modes: Cool, Heat, Off, EmergencyHeat (model-dependent).

Fan Control

POST https://lyric.alarmnet.com/api/v2/locations/5407233/devices/
     LCC-48A2E61BB64F/fan/changeableValues

Body: { "mode": "Circulate" }

Valid modes: Auto, On, Circulate.

Temperature Setpoint

PUT https://lyric.alarmnet.com/api/v2/locations/5407233/devices/
    LCC-48A2E61BB64F/thermostat/Coolsetpoint

Body: {
  "thermostatSetpointStatus": "HoldUntil",
  "holdUntil": "21:00:00",
  "thermostatSetpoint": "76",
  "unit": "Fahrenheit"
}

The thermostatSetpointStatus controls the hold behavior: HoldUntil for a temporary hold with a time, PermanentHold for indefinite, or NoHold to resume the schedule. For heat mode, the endpoint is /thermostat/Heatsetpoint instead.

Required Headers

Header Value Notes
Authorization Bearer <JWT> 15-minute access token
User-Agent Lyric/6.19.1.0 (iPhone; iOS 26.3.1; Scale/3.00) App UA for API calls
Ocp-Apim-Subscription-Key d146f1f6f74a40f2812f4ca204bce3d2 Azure API gateway key
AppVer 6.19.1.0 App version
ActivityId <UUID> Unique per request
MobileClientTime 2026-03-12 18:44:23 Current timestamp

The app uses different User-Agent strings for different contexts: a browser-like UA for the Azure B2C webview authentication, and the Lyric app UA for API data calls. Mimicking both correctly matters.

Building the Integration

Unlike the Govee pool thermometer — which was a 50-line bash script — the Resideo integration needed Python. The OAuth2/PKCE authentication flow, automatic token refresh, and bidirectional MQTT command handling are all legitimately complex.

Architecture

The Python script handles four responsibilities:

  1. Authentication: try the refresh token first, fall back to the full PKCE login flow
  2. Polling: GET device data every 5 minutes, publish to MQTT
  3. Commands: subscribe to MQTT command topics, translate to Resideo API calls
  4. Token management: refresh the access token every 15 minutes, persist the refresh token to disk

Token Refresh

The critical path — this is what keeps the integration running indefinitely:

def do_token_refresh():
    resp = requests.post(TOKEN_URL, data={
        "grant_type": "refresh_token",
        "client_id": CLIENT_ID,
        "refresh_token": stored_refresh_token,
        "scope": SCOPE,
    })
    tokens = resp.json()
    # Resideo rotates the refresh token on each use — save the new one
    save_refresh_token(tokens["refresh_token"])
    return tokens["access_token"]

The refresh token is rotated on every use — each refresh response includes a new refresh token that replaces the old one. If you lose the current refresh token (disk failure, container reset without volume mount), you’ll need to redo the full login. That’s why the token is persisted to a mounted volume.

MQTT Design: Separate Topics, Not JSON

A lesson I learned the hard way. My first attempt used a single JSON payload with value_template extraction in HA’s MQTT auto-discovery:

# DON'T DO THIS
state_topic: "resideo/thermostat/state"
value_template: "{{ value_json.mode }}"

This works until you change the JSON structure or retained messages from old configs conflict with new ones. HA fails silently, and you spend an hour wondering why your thermostat entity shows “unknown.”

The working approach uses one MQTT topic per value:

resideo/thermostat/mode           → "cool"
resideo/thermostat/fan_mode       → "circulate"
resideo/thermostat/target_temp    → "77.0"
resideo/thermostat/current_temp   → "77.0"
resideo/thermostat/outdoor_temp   → "84.0"
resideo/thermostat/outdoor_humidity → "70.0"
resideo/thermostat/action         → "cooling"
resideo/thermostat/hold_status    → "Following Schedule"
resideo/thermostat/schedule_period → "Monday / Away"

More topics, but each one is a simple string. HA auto-discovery handles it cleanly.

HA MQTT Climate Entity Gotchas

Two pitfalls that cost me time:

preset_modes must not include "none". Home Assistant handles the “no preset active” state implicitly. If you include "none" in the preset list, HA rejects the entire discovery config with a cryptic validation error.

Retained messages persist across restarts. If you change your discovery config structure (say, switching from JSON templates to separate topics), the old retained config message stays in Mosquitto. HA reads both the old and new configs on startup and gets confused. Fix: publish empty retained messages (mosquitto_pub -t <topic> -r -n) to clear stale configs before publishing updated ones.

Docker Setup

FROM python:3.13-alpine
RUN pip install --no-cache-dir requests paho-mqtt
COPY resideo_thermostat.py /app/resideo_thermostat.py
CMD ["python3", "-u", "/app/resideo_thermostat.py"]
# docker-compose.yml
resideo-thermostat:
  build:
    context: .
    dockerfile: Dockerfile.resideo
  container_name: resideo-thermostat
  network_mode: host
  restart: unless-stopped
  volumes:
    - ./resideo-data:/data
  environment:
    - TZ=America/New_York

The resideo-data volume stores the refresh token and login credentials. The credentials file is only used as a fallback when the refresh token expires after 90 days of downtime.

The Result

After docker compose up -d --build resideo-thermostat:

Climate entity (climate.thermostat):

Sensor entities:

All controls are bidirectional. Changing the mode or temperature in Home Assistant sends the API call to Resideo and the thermostat updates within seconds.

The Full Data Path

TH6320WF2003 (thermostat on wall)
  ↓ WiFi → Internet
Resideo Cloud (lyric.alarmnet.com)
  ↓ HTTPS (polled every 5 min + on-demand commands)
resideo-thermostat container (NUC)
  ↓↑ MQTT (publish state + subscribe commands)
Mosquitto → Home Assistant
  → climate.thermostat + sensor entities

Bidirectional: HA dashboard → MQTT command → Python script → Resideo API → thermostat on wall. About 2-3 seconds end to end.

Govee vs Resideo: Two Worlds

Having reverse-engineered both the Govee pool thermometer and the Resideo thermostat in the same week, the contrast is striking:

Aspect Govee H5109 Resideo TH6320WF
Auth Simple Bearer JWT Azure AD B2C + PKCE
Token life ~60 days 15 min access / 90 day refresh
Renewal Manual (re-intercept) Automatic (refresh token)
API style POST with empty body RESTful GET/PUT/POST
Control Read-only Full bidirectional
Script Bash, 50 lines Python, 500+ lines
Container 6MB Alpine ~50MB Python Alpine

The Govee API feels like it was built by a startup that shipped fast. The Resideo API feels like it was designed by an enterprise team with security consultants and an Azure contract. Both work perfectly once you understand them.

Lessons Learned

Azure AD B2C is everywhere. Even a thermostat company uses Microsoft’s identity platform. The B2C login flow — authorize, SelfAsserted, confirmed, token — is a pattern you’ll encounter across many enterprise IoT apps. Learning it once pays off.

PKCE makes automation harder, not impossible. The code challenge/verifier mechanism prevents token interception on the network. But since we control the entire client, we generate our own PKCE pairs. The security model protects against eavesdroppers, not legitimate device owners running their own code.

Refresh tokens make integrations sustainable. The Govee integration needs manual token renewal every 60 days. The Resideo integration runs indefinitely because the refresh token is automatically renewed on each use. As long as the script stays running (or at least runs once every 90 days), it never needs human intervention.

Use separate MQTT topics. One topic per value beats JSON payloads with templates every time. It’s more topics to manage, but HA’s auto-discovery handles them cleanly, retained messages don’t conflict, and debugging is trivial — you can mosquitto_sub -t resideo/thermostat/# and see every value individually.

Mimic the real app’s headers faithfully. The Ocp-Apim-Subscription-Key is an Azure API Management gate. The User-Agent matters. The app uses different UAs for auth flows versus API calls. Skip any of these and requests may silently fail or get rate-limited.

Enterprise APIs have better data quality. The Resideo API is well-structured, versioned, and returns comprehensive device state in clean JSON. The trade-off is auth complexity, but once you crack that, the data and control surfaces are excellent. Every field means what it says, no offset-10 encoding or misleading register names to reverse-engineer.

Security Notes