Reverse Engineering the Resideo Thermostat API: Full Home Assistant Control Without a Developer Key
March 12, 2026
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
- Install:
brew install --cask proxyman - On iPhone: set WiFi proxy to the Mac’s IP, port 9090
- Install the Proxyman CA certificate on the iPhone and enable full trust
- Add SSL Proxying rules for
lyric.alarmnet.comandlyricprod.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:
- An Azure AD B2C tenant at
lyricprod.b2clogin.com - OAuth2 Authorization Code flow with PKCE (Proof Key for Code Exchange)
- A custom B2C policy called
B2C_1A_SignIn_Mob_HH - 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:
- Authentication: try the refresh token first, fall back to the full PKCE login flow
- Polling: GET device data every 5 minutes, publish to MQTT
- Commands: subscribe to MQTT command topics, translate to Resideo API calls
- 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):
- Current temperature, target temperature (adjustable from HA)
- Modes: Off, Heat, Cool — selectable from HA
- Fan modes: Auto, On, Circulate — selectable from HA
- HVAC action: cooling / heating / idle
Sensor entities:
- Outdoor Temperature
- Outdoor Humidity
- Hold Status (“Following Schedule” / “Hold Until 21:00:00” / “Permanent Hold”)
- Schedule Period (“Monday / Away”)
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
- All traffic interception was performed on my own devices, on my own network, against my own account.
- The
credentials.jsonfile contains your Resideo password in plaintext. Restrict file permissions and keep it out of version control. - The refresh token grants full access to your thermostat. Treat the token file as a secret.
- The
Ocp-Apim-Subscription-Keyis an app-level gateway key shared across all Resideo app installations. It’s not per-user, but don’t publish it unnecessarily. - Resideo could change their B2C policy, client ID, or API structure in any app update. Enterprise APIs tend to be more stable than startup APIs, but nothing is guaranteed.
- If Resideo ever processes developer API key applications again, switch to the official integration. Until then, this works.