nest-thermostat-mcp
by SarjuThakkar
README.md
# nest-thermostat-mcp
Voice control for Nest thermostats from a [Pebble Index](https://www.pebble.computer/)
ring, over Google's Smart Device Management API.
Say *"set the upstairs to 70"* or *"how cold is it downstairs"* and it happens.
```
Pebble ring ──MCP/HTTPS──▶ this server ──REST──▶ Google SDM ──▶ thermostats
(voice) (Pi, :8004) (cloud)
```
Runs on the same Raspberry Pi as the rest of my home services — see
[pi-home-services](https://github.com/SarjuThakkar/pi-home-services) for the
Docker Compose orchestration and Cloudflare Tunnel setup.
## Why the cloud API, when the vacuum next door is local
Reluctantly. [dreame-vacuum-mcp](https://github.com/SarjuThakkar/dreame-vacuum-mcp)
runs entirely on the LAN over Matter, and I wanted the same here. Nest doesn't
allow it:
- **Only the 4th-gen (2024) Nest Learning Thermostat speaks Matter.** Older
units — 3rd gen, Thermostat E, the 2020 Nest Thermostat — expose no Matter
endpoint, and Google does not bridge them onto a third-party Matter fabric.
- **There is no documented local API.** The old unofficial local protocols died
with the Works with Nest shutdown.
So SDM it is, with the consequences that implies: **an internet outage takes
this out**, and every command is a round trip through Google.
**Nest Protect is not supported by SDM at all** — smoke/CO alarms were only ever
in the legacy Works with Nest API, which is deprecated and closed to new users.
This server is thermostats only.
## Tools
| Tool | What it does |
|---|---|
| `list_thermostats` | Which thermostats exist and what they're called |
| `thermostat_status(name)` | Temperature, humidity, setpoint, mode, whether it's actively heating/cooling. Omit the name for all of them |
| `set_temperature(name, temperature)` | Set the target. Also takes `heat`/`cool` for a thermostat in auto mode |
| `set_mode(name, mode)` | heat / cool / auto / off |
| `set_eco(name, on)` | Energy-saving mode |
| `set_fan(name, on, minutes)` | Run the fan on its own |
Names are matched against whatever Google reports — the thermostat's custom
name if it has one, otherwise its room. `"the upstairs thermostat"` finds
`Upstairs`, and a typo like `"downstars"` still resolves. A name that genuinely
doesn't exist is reported back with the real list rather than guessed at,
because with two thermostats a wrong guess changes the temperature in the wrong
part of the house.
## API behaviour worth knowing
Verified against the SDM documentation; the awkward parts are handled in the
server rather than left to the caller.
- **Everything on the wire is Celsius**, always — even when the thermostat
displays Fahrenheit. The display unit comes from the `Settings` trait, and
this server converts in both directions so a spoken "70" means 70 in whatever
unit that thermostat shows.
- **Which setpoint command is legal depends on the current mode.** `SetHeat`
only works in `HEAT`, `SetCool` only in `COOL`, `SetRange` only in
`HEATCOOL`. Sending the wrong one is a 400, so the mode is read before every
write and the right command chosen.
- **Auto mode is genuinely ambiguous for a single number.** "Set it to 70" has
no correct meaning for a thermostat holding a range, so the server says so and
asks for both ends instead of picking one.
- **Eco mode silently overrides the setpoint.** Writing a setpoint with Eco on
is accepted and does nothing visible. `set_temperature` therefore turns Eco
off first and says that it did, rather than reporting a temperature that
won't be held.
- **A mis-transcribed number is a real hazard** when the output is a furnace.
Anything outside 45–95°F is refused with an explanation.
- **Offline thermostats don't report a stale temperature.** If Google says the
device is offline, the status says only that.
## Setup
The Google side is manual and has to be done once, by hand, in a browser.
### 1. Register for Device Access — $5, one-time, non-refundable
<https://console.nest.google.com/device-access> → accept the terms, pay, then
**Create project**. Note the **project ID** (a UUID).
### 2. Create an OAuth client
In [Google Cloud Console](https://console.cloud.google.com/) for the same account:
1. **APIs & Services → Library** → enable **Smart Device Management API**.
2. **APIs & Services → OAuth consent screen** → **External**. (*Internal* is
only selectable on a Google Workspace account; a personal Gmail has no
organisation, so it's greyed out.)
3. **Then click "Publish app" so the status reads "In production".** This
matters more than it looks: while the status is *Testing*, Google
**revokes the refresh token after 7 days**, and the server starts answering
"Google rejected the saved login" once a week until you re-run the OAuth
helper. In production the refresh token lasts indefinitely.
Publishing an app with a sensitive scope normally invites verification, but
you can ignore that — an unverified app still works for its own owner. You
just get an "unverified app" interstitial during authorisation; click
**Advanced → Go to … (unsafe)** to continue.
4. **APIs & Services → Credentials → Create credentials → OAuth client ID** →
*Web application*. Add `https://www.google.com` as an **Authorized redirect
URI** — exactly, with no trailing slash.
5. Copy the **client ID** and **client secret**.
6. Back in the Device Access console, paste the client ID into your project.
Use the **same Google account the thermostats are on** throughout. A project
created on a different account builds fine and then reports zero devices.
### 3. Authorize and get a refresh token
```bash
python3 get_refresh_token.py
```
It prints a URL, you approve access in the browser, you paste back the `code`
from the address bar, and it prints the four environment values.
### 4. Run it
```bash
python3 test_logic.py # offline tests, no credentials needed
cp .env.example .env # fill in the four values above
docker build -t nest-mcp .
docker run -p 8004:8000 --env-file .env nest-mcp
```
Point the Pebble app at `https://<your-host>/mcp` with the bearer token.
## Environment variables
| Variable | Meaning |
|---|---|
| `MCP_BEARER_TOKEN` | Static token Pebble sends as `Authorization: Bearer <token>` |
| `NEST_PROJECT_ID` | Device Access project ID (UUID) |
| `NEST_CLIENT_ID` | OAuth client ID |
| `NEST_CLIENT_SECRET` | OAuth client secret |
| `NEST_REFRESH_TOKEN` | From `get_refresh_token.py`; doesn't expire on its own |
| `PORT` | Port inside the container (default 8000) |
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues