Polling Google Nest Thermostats from Python with the SDM API
I have five Google Nest thermostats and a Pi 5 collector that already logs DS18B20 probes, DHT11s and CT clamps into one SQLite database. The thermostats were the last island. They know things my own sensors do not — whether the HVAC is actually calling for heat, what the setpoint is, what the Nest’s own ambient sensor thinks the room is — and until I got them into the same table I could not put any of it on the same time axis as my probes.
The Smart Device Management (SDM) API is the only supported route in. It works. It is also the most bureaucratic integration on my rig, and the thing that bites you is not the OAuth dance, it is the rate limit, because it is charged per device instance, and I have five of them.
The quota is the design constraint
Google publishes the limits for Device Access. For a THERMOSTAT device instance, the documented limit is 5 QPM or 100 QPH (queries per minute / per hour). Those are Google’s published figures, not mine.
Do the arithmetic before you write any code, because it decides your architecture:
| Poll interval | Queries per device per hour | Inside 100 QPH? |
|---|---|---|
| 15 s | 240 | No |
| 30 s | 120 | No |
| 60 s | 60 | Yes |
| 90 s | 40 | Yes |
| 5 min | 12 | Yes, with lots of headroom |
One minute per device is the fastest sustainable poll, and that leaves only 40 QPH of slack for retries, restarts and any command you send. My other sensors log at one-minute intervals, so on paper that lines up neatly — but a collector restart that re-polls every device immediately, or a retry storm during a Wi-Fi wobble, eats the hourly budget fast. A 429 is not a transient you should hammer at.
Two things follow. First, use devices.list (one call returning all devices with their traits) rather than five devices.get calls. The per-device instance limit still applies, but you spend one query per device per poll instead of one query plus per-device overhead, and you get a consistent snapshot across the fleet. Second, back off on 429 hard, and budget your own quota rather than trusting the server to tell you.
The alternative is Pub/Sub events, which push trait changes instead of you pulling them. It is the right answer if you want to react instantly to a setpoint change. I deliberately did not use it for logging, because an event stream gives you samples when something changes, and a time-series database wants samples on a regular grid. Reconstructing a fixed-interval series from an irregular event stream means you are interpolating, and I would rather log a known-cadence poll than store an interpolation and forget I made it. I may add Pub/Sub later as a second channel for edge detection. For the logger, polling is the honest shape.
Auth, briefly
The setup is: a Device Access project (there is a one-time registration fee, currently listed on Google’s Device Access site — check it there rather than trusting a blog), a GCP project with the SDM API enabled, an OAuth client, and a one-time consent flow through the Partner Connections Manager that links your Google Home account to the project. That gets you a refresh token.
After that the daily reality is simple: exchange the refresh token for an access token, and cache it until shortly before it expires. The token endpoint returns expires_in; I refresh at 80% of that rather than waiting for a 401.
import time, httpx
TOKEN_URL = "https://oauth2.googleapis.com/token"
class NestAuth:
def __init__(self, client_id, client_secret, refresh_token):
self._creds = dict(client_id=client_id,
client_secret=client_secret,
refresh_token=refresh_token,
grant_type="refresh_token")
self._token = None
self._expires_at = 0.0
async def token(self, client: httpx.AsyncClient) -> str:
if self._token and time.monotonic() < self._expires_at:
return self._token
r = await client.post(TOKEN_URL, data=self._creds, timeout=15)
r.raise_for_status()
j = r.json()
self._token = j["access_token"]
# Refresh at 80% of lifetime; never trust the clock to the last second.
self._expires_at = time.monotonic() + j["expires_in"] * 0.8
return self._token
Keep the refresh token out of the repo. Mine lives in a file readable only by the collector user, not in the code and not in an environment variable that shows up in ps output for a child process.
The poll itself
The response shape is documented: devices.list returns each device with a traits dict. The ones I log are sdm.devices.traits.Temperature.ambientTemperatureCelsius, sdm.devices.traits.Humidity.ambientHumidityPercent, sdm.devices.traits.ThermostatHvac.status (HEATING / COOLING / OFF), sdm.devices.traits.ThermostatMode.mode, sdm.devices.traits.ThermostatTemperatureSetpoint (heatCelsius / coolCelsius), and sdm.devices.traits.Connectivity.status.
Note that ThermostatTemperatureSetpoint may contain only one of heatCelsius / coolCelsius depending on the current mode. Assuming both are present is the most common way this integration crashes at 3 a.m. when the mode changes.
import asyncio
BASE = "https://smartdevicemanagement.googleapis.com/v1"
def _flatten(dev: dict) -> dict:
t = dev.get("traits", {})
sp = t.get("sdm.devices.traits.ThermostatTemperatureSetpoint", {})
return {
"device_id": dev["name"].rsplit("/", 1)[-1],
"name": t.get("sdm.devices.traits.Info", {}).get("customName") or "",
"online": t.get("sdm.devices.traits.Connectivity", {}).get("status") == "ONLINE",
"temp_c": t.get("sdm.devices.traits.Temperature", {})
.get("ambientTemperatureCelsius"),
"rh_pct": t.get("sdm.devices.traits.Humidity", {})
.get("ambientHumidityPercent"),
"hvac": t.get("sdm.devices.traits.ThermostatHvac", {}).get("status"),
"mode": t.get("sdm.devices.traits.ThermostatMode", {}).get("mode"),
# Either key may be absent depending on mode. Do not assume.
"setpoint_heat_c": sp.get("heatCelsius"),
"setpoint_cool_c": sp.get("coolCelsius"),
}
async def poll_all(auth, client, project_id, attempt=0):
tok = await auth.token(client)
r = await client.get(f"{BASE}/enterprises/{project_id}/devices",
headers={"Authorization": f"Bearer {tok}"}, timeout=20)
if r.status_code == 429:
# Exponential backoff, capped. Quota is per device instance;
# hammering costs the whole fleet, not just this call.
delay = min(60 * 2 ** attempt, 900)
await asyncio.sleep(delay)
if attempt >= 4:
return []
return await poll_all(auth, client, project_id, attempt + 1)
r.raise_for_status()
return [_flatten(d) for d in r.json().get("devices", [])
if d.get("type", "").endswith("THERMOSTAT")]
That returns plain dicts which go into the same readings insert as every other sensor, with the thermostat’s device ID as sensor_id. From SQLite’s point of view a Nest is just another probe with a slow sample rate.
The part nobody warns you about: the number is not a measurement
ambientTemperatureCelsius comes back as a float. It looks like a precise reading. It is not a reading in the sense a DS18B20 conversion is a reading.
What the API gives you is the thermostat’s reported ambient temperature, which is a smoothed, internally-processed value that Google updates on its own schedule and which lags the actual air. You can see this directly if you put a DS18B20 near a thermostat and overlay the two traces: the DS18B20 shows the fast structure and the Nest trace is a slower, rounded version of the same event. The method to check it on your own rig is straightforward — co-locate a probe, log both on the same time axis, and look at the lag between the moment ThermostatHvac.status flips to HEATING and the moment each trace starts to move. I run exactly that comparison; the useful output is the shape difference, not a single error figure, and I am not going to quote a drift number I have not characterised properly.
The consequence for logging: treat the Nest ambient value as a reported state, not as a temperature measurement, and never blend it into the same averaged series as your own probes. I keep them as separate sensor_ids and label the Chart.js series accordingly. Where the Nest data is genuinely irreplaceable is ThermostatHvac.status — that is ground truth about what the equipment is doing, and no amount of DS18B20s will tell you it directly.
What went wrong
Polling all five at once on a fresh start. My first version fired every device poll simultaneously on collector startup, and every restart during development spent a chunk of the hourly budget in one burst. Now the collector persists the last successful poll time per device and refuses to poll again inside the interval, even across a restart. Quota state that lives only in memory is quota state you lose exactly when you are most likely to burn it.
Treating 429 as a normal retry. The generic retry wrapper I use for flaky I2C is wrong here. A rate limit is not a transient fault, it is the server telling you your budget is spent, and the correct response is to wait a long time, not to try again in two seconds.
Missing setpoint keys. Covered above; it took a mode change to find it. My range gate now checks for None before anything arithmetic touches those fields.
Devices going OFFLINE and still returning a payload. Connectivity.status can be OFFLINE while the rest of the traits still come back populated with the last known values. That is a frozen sensor by another name — a plausible, unchanging number that looks like a very stable room. I now drop the temperature and humidity rows entirely when Connectivity.status is not ONLINE, and log the connectivity state as its own channel so the gap in the data has a visible cause.
Consent expiring. The link between the Google account and the Device Access project is revocable from the Home app, and if it goes, the refresh token stops working. That is not an outage you can code around; it is one you need an alert for. I treat a persistent invalid_grant as a page-me condition rather than a log line.
Is it worth it
For the HVAC state channel, yes, unreservedly. Knowing exactly when the system called for heat turns every other temperature trace in the house from a curve into an experiment with a known stimulus. For the ambient temperature and humidity, not really — a DS18B20 on a flying lead costs almost nothing, samples as fast as I want, and I know precisely what it is measuring. The SDM API’s value is that it tells me what the equipment is doing. It is a poor substitute for a sensor and an excellent source of state.