Skip to main content
Glama
SarjuThakkar

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) |