|
| 1 | +--- |
| 2 | +title: Direct SmartThings Integration (No Hub) |
| 3 | +description: Control SmartThings devices directly from Apollo ESPHome sensors using OAuth2 — no Home Assistant or hub required. |
| 4 | +--- |
| 5 | + |
| 6 | +# Apollo Sensors + SmartThings Direct Control |
| 7 | + |
| 8 | +!!! note "Compatible Devices" |
| 9 | + This works with any Apollo ESPHome device running ESPHome: MTR-1, MSR-2, AIR-1, and others. The OAuth2 token block is fully device-agnostic — just swap the `packages:` line for your device. |
| 10 | + |
| 11 | +## Background |
| 12 | + |
| 13 | +Since early 2025, Samsung SmartThings no longer supports permanent Personal Access Tokens (PAT). All API access now requires OAuth2 tokens that expire every 24 hours. This makes direct ESP32 integrations tricky — but not impossible. |
| 14 | + |
| 15 | +This tutorial shows how to make your Apollo MTR-1 control SmartThings lights directly, with zero external servers, no Home Assistant, and no intermediate hub. The ESP32 handles the full OAuth2 token lifecycle autonomously — including persisting the refresh token across reboots using NVS flash storage. Once set up, it runs forever without any manual intervention. |
| 16 | + |
| 17 | +**What you will achieve:** |
| 18 | + |
| 19 | +- Zone-based presence detection on the MTR-1 that triggers SmartThings lights on/off instantly |
| 20 | +- Fully autonomous OAuth2 token renewal every 20 hours |
| 21 | +- Token persistence across power cuts and reboots via NVS flash |
| 22 | + |
| 23 | +## What You Need |
| 24 | + |
| 25 | +- Apollo MTR-1 (the same pattern works for MSR-2, AIR-1, and other Apollo ESPHome devices) |
| 26 | +- A Samsung SmartThings account |
| 27 | +- ESPHome installed on your PC (`pip install esphome`) |
| 28 | +- One or more SmartThings-connected lights or switches |
| 29 | +- About 30 minutes for the initial setup |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +## Part 1: Create a SmartThings OAuth2 App |
| 34 | + |
| 35 | +This is a one-time setup. You need to register an OAuth2 client in the SmartThings Developer Workspace to get your `client_id` and `client_secret`. |
| 36 | + |
| 37 | +**Step 1 — Create a project:** |
| 38 | + |
| 39 | +1. Go to [https://developer.smartthings.com](https://developer.smartthings.com) and sign in with your Samsung account |
| 40 | +2. Click **"Create Project"** → select **"Automation for SmartThings"** |
| 41 | +3. Give it any name, e.g. "Apollo MTR-1" |
| 42 | + |
| 43 | +**Step 2 — Add an OAuth2 Client:** |
| 44 | + |
| 45 | +1. Inside your project, go to **Automation → OAuth2** |
| 46 | +2. Set the Redirect URI to: `https://oauth.pstmn.io/v1/callback` |
| 47 | +3. Set scopes: `r:devices:*` `w:devices:*` `x:devices:*` |
| 48 | +4. Save — you will receive a **Client ID** and a **Client Secret**. Note both down. |
| 49 | + |
| 50 | +--- |
| 51 | + |
| 52 | +## Part 2: Get Your First Token Pair (One-Time via Browser) |
| 53 | + |
| 54 | +SmartThings requires a one-time browser-based authorization. After this, the ESP32 renews everything automatically. |
| 55 | + |
| 56 | +**Step 1 — Open this URL in your browser** (replace `YOUR_CLIENT_ID`): |
| 57 | + |
| 58 | +``` |
| 59 | +https://api.smartthings.com/oauth/authorize?client_id=YOUR_CLIENT_ID&scope=x:devices:*+w:devices:*+r:devices:*&response_type=code&redirect_uri=https://oauth.pstmn.io/v1/callback |
| 60 | +``` |
| 61 | + |
| 62 | +Log in with your Samsung account and authorize the app. You will be redirected to a URL like: |
| 63 | + |
| 64 | +``` |
| 65 | +https://oauth.pstmn.io/v1/callback?code=XXXXXX |
| 66 | +``` |
| 67 | + |
| 68 | +Copy the `code` value. **It expires in about 60 seconds — act fast.** |
| 69 | + |
| 70 | +**Step 2 — Exchange the code for tokens (PowerShell on Windows):** |
| 71 | + |
| 72 | +```powershell |
| 73 | +$b64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("YOUR_CLIENT_ID:YOUR_CLIENT_SECRET")) |
| 74 | +Invoke-RestMethod -Method Post ` |
| 75 | + -Uri "https://api.smartthings.com/oauth/token" ` |
| 76 | + -Headers @{Authorization="Basic $b64"} ` |
| 77 | + -ContentType "application/x-www-form-urlencoded" ` |
| 78 | + -Body "grant_type=authorization_code&code=YOUR_CODE&redirect_uri=https://oauth.pstmn.io/v1/callback" |
| 79 | +``` |
| 80 | + |
| 81 | +You will receive a response like: |
| 82 | + |
| 83 | +``` |
| 84 | +access_token : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| 85 | +refresh_token : yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy |
| 86 | +expires_in : 86399 |
| 87 | +``` |
| 88 | + |
| 89 | +Save both tokens. You will enter them into the ESPHome config exactly once. |
| 90 | + |
| 91 | +**How to generate the Base64 credential string** you will need later: |
| 92 | + |
| 93 | +```powershell |
| 94 | +[Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("YOUR_CLIENT_ID:YOUR_CLIENT_SECRET")) |
| 95 | +``` |
| 96 | + |
| 97 | +Save this Base64 string too. |
| 98 | + |
| 99 | +--- |
| 100 | + |
| 101 | +## Part 3: Create Virtual Switches and Find Device IDs |
| 102 | + |
| 103 | +To map your physical MTR-1 zones to SmartThings routines without a hub, we need Virtual Switches. The MTR-1 will send ON/OFF commands to these virtual switches, which you can then use in SmartThings to trigger your actual lights. |
| 104 | + |
| 105 | +**Step 1 — Create Virtual Switches** |
| 106 | + |
| 107 | +Log into your account @ [my.smartthings.com](https://my.smartthings.com) > advanced > add device > cloud > switch |
| 108 | + |
| 109 | +**Step 2 — Find Your SmartThings Device IDs** |
| 110 | + |
| 111 | +In the my.smartthings webapp you can copy your new virtual device IDs. |
| 112 | + |
| 113 | +For each light or switch you want to control, you need its Device ID (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). |
| 114 | + |
| 115 | +--- |
| 116 | + |
| 117 | +## Part 4: The ESPHome Configuration |
| 118 | + |
| 119 | +Below is the complete, production-tested YAML. Replace all `YOUR_*` placeholders with your actual values. |
| 120 | + |
| 121 | +!!! tip "Key Design Decisions" |
| 122 | + 1. **`wifi: on_connect` instead of `on_boot`** — the ESP-IDF HTTP stack is not ready at boot time. Using `on_connect` with a 15-second delay guarantees the network is fully available before the first token refresh fires. |
| 123 | + 2. **`script: mode: single`** — prevents a second token refresh from being triggered while the first one is still running. This is critical during router reboots or mesh WiFi reconnects where `wifi: on_connect` can fire multiple times in rapid succession. |
| 124 | + 3. **`restore_value: yes` on `st_refresh_token`** — saves the latest refresh token to NVS flash after every successful renewal. It survives reboots and power cuts. The `initial_value` in the YAML is only ever used on the very first flash. |
| 125 | + 4. **`body: !lambda |-`** — plain string bodies are silently ignored by the ESP-IDF HTTP client. The lambda syntax is required for the POST body to actually be sent. |
| 126 | + |
| 127 | +```yaml |
| 128 | +substitution: |
| 129 | + name: "apollo-mtr1" |
| 130 | + friendly_name: "Apollo MTR-1" |
| 131 | + |
| 132 | +esphome: |
| 133 | + name: ${name} |
| 134 | +packages: |
| 135 | + - github://ApolloAutomation/MTR-1/Integrations/ESPHome/MTR-1_Factory.yaml |
| 136 | + |
| 137 | +http_request: |
| 138 | + id: http_request_id |
| 139 | + verify_ssl: false |
| 140 | + timeout: 10s |
| 141 | + buffer_size_rx: 1024 |
| 142 | + buffer_size_tx: 512 |
| 143 | + |
| 144 | +globals: |
| 145 | + - id: st_access_token |
| 146 | + type: std::string |
| 147 | + restore_value: no |
| 148 | + initial_value: '"Bearer YOUR_ACCESS_TOKEN"' |
| 149 | + - id: st_refresh_token |
| 150 | + type: std::string |
| 151 | + restore_value: yes # saved to NVS flash, survives reboots |
| 152 | + initial_value: '"YOUR_REFRESH_TOKEN"' |
| 153 | + |
| 154 | +script: |
| 155 | + - id: refresh_oauth_token |
| 156 | + mode: single # ignore duplicate calls while refresh is in progress |
| 157 | + then: |
| 158 | + - lambda: |- |
| 159 | + ESP_LOGI("oauth", ">>> Refresh START"); |
| 160 | + - http_request.send: |
| 161 | + method: POST |
| 162 | + url: "https://api.smartthings.com/oauth/token" |
| 163 | + capture_response: true |
| 164 | + max_response_buffer_size: 1024 |
| 165 | + request_headers: |
| 166 | + Authorization: "Basic YOUR_BASE64_CLIENT_ID_SECRET" |
| 167 | + Content-Type: "application/x-www-form-urlencoded" |
| 168 | + body: !lambda |- |
| 169 | + return std::string("grant_type=refresh_token&refresh_token=") + id(st_refresh_token); |
| 170 | + on_response: |
| 171 | + then: |
| 172 | + - lambda: |- |
| 173 | + ESP_LOGI("oauth", ">>> HTTP Status: %d", response->status_code); |
| 174 | + if (response->status_code == 200) { |
| 175 | + json::parse_json(body, [](JsonObject root) -> bool { |
| 176 | + std::string new_access = root["access_token"] | ""; |
| 177 | + std::string new_refresh = root["refresh_token"] | ""; |
| 178 | + if (!new_access.empty()) { |
| 179 | + id(st_access_token) = "Bearer " + new_access; |
| 180 | + ESP_LOGI("oauth", ">>> Access token updated"); |
| 181 | + } |
| 182 | + if (!new_refresh.empty()) { |
| 183 | + id(st_refresh_token) = new_refresh; |
| 184 | + ESP_LOGI("oauth", ">>> Refresh token updated and saved to NVS"); |
| 185 | + } |
| 186 | + return true; |
| 187 | + }); |
| 188 | + } else { |
| 189 | + ESP_LOGW("oauth", ">>> Refresh FAILED: %s", body.c_str()); |
| 190 | + } |
| 191 | +
|
| 192 | +wifi: |
| 193 | + on_connect: |
| 194 | + - delay: 15s |
| 195 | + - lambda: |- |
| 196 | + ESP_LOGI("oauth", ">>> WiFi ready, firing token refresh"); |
| 197 | + - script.execute: refresh_oauth_token |
| 198 | + |
| 199 | +interval: |
| 200 | + - interval: 20h # tokens expire after 24h, refresh safely at 20h |
| 201 | + then: |
| 202 | + - lambda: |- |
| 203 | + ESP_LOGI("oauth", ">>> 20h interval token refresh"); |
| 204 | + - script.execute: refresh_oauth_token |
| 205 | + |
| 206 | +# --------------------------------------------------------------- |
| 207 | +# PRESENCE ZONES |
| 208 | +# The MTR-1 uses the LD2450 radar which provides X/Y coordinates |
| 209 | +# in mm for up to 3 simultaneous targets. Define rectangular zones |
| 210 | +# based on your room layout. Use the ESPHome web UI live view to |
| 211 | +# find the right coordinates by walking through your space. |
| 212 | +# X = left/right from sensor center, Y = distance forward |
| 213 | +# --------------------------------------------------------------- |
| 214 | + |
| 215 | +sensor: |
| 216 | + - platform: ld2450 |
| 217 | + target_1: |
| 218 | + x: { name: "My T1 X", id: my_t1_x, internal: true } |
| 219 | + y: { name: "My T1 Y", id: my_t1_y, internal: true } |
| 220 | + target_2: |
| 221 | + x: { name: "My T2 X", id: my_t2_x, internal: true } |
| 222 | + y: { name: "My T2 Y", id: my_t2_y, internal: true } |
| 223 | + target_3: |
| 224 | + x: { name: "My T3 X", id: my_t3_x, internal: true } |
| 225 | + y: { name: "My T3 Y", id: my_t3_y, internal: true } |
| 226 | + |
| 227 | +binary_sensor: |
| 228 | + # Zone 1 — replace coordinates and device ID with your own |
| 229 | + - platform: template |
| 230 | + name: "SmartThings Zone Kitchen Counter" |
| 231 | + lambda: |- |
| 232 | + return ( |
| 233 | + (id(my_t1_x).state >= -1400 && id(my_t1_x).state <= 950 && id(my_t1_y).state >= 700 && id(my_t1_y).state <= 2700) || |
| 234 | + (id(my_t2_x).state >= -1400 && id(my_t2_x).state <= 950 && id(my_t2_y).state >= 700 && id(my_t2_y).state <= 2700) || |
| 235 | + (id(my_t3_x).state >= -1400 && id(my_t3_x).state <= 950 && id(my_t3_y).state >= 700 && id(my_t3_y).state <= 2700) |
| 236 | + ); |
| 237 | + on_press: |
| 238 | + - http_request.send: |
| 239 | + url: "https://api.smartthings.com/v1/devices/YOUR_DEVICE_ID_1/commands" |
| 240 | + method: POST |
| 241 | + request_headers: |
| 242 | + Authorization: !lambda return id(st_access_token).c_str(); |
| 243 | + Content-Type: "application/json" |
| 244 | + body: '{"commands": [{"component": "main", "capability": "switch", "command": "on"}]}' |
| 245 | + on_release: |
| 246 | + - http_request.send: |
| 247 | + url: "https://api.smartthings.com/v1/devices/YOUR_DEVICE_ID_1/commands" |
| 248 | + method: POST |
| 249 | + request_headers: |
| 250 | + Authorization: !lambda return id(st_access_token).c_str(); |
| 251 | + Content-Type: "application/json" |
| 252 | + body: '{"commands": [{"component": "main", "capability": "switch", "command": "off"}]}' |
| 253 | + |
| 254 | + # Zone 2 — add as many zones as you need, one per light/switch |
| 255 | + - platform: template |
| 256 | + name: "SmartThings Zone Dining Table" |
| 257 | + lambda: |- |
| 258 | + return ( |
| 259 | + (id(my_t1_x).state >= 1000 && id(my_t1_x).state <= 2600 && id(my_t1_y).state >= 1000 && id(my_t1_y).state <= 2000) || |
| 260 | + (id(my_t2_x).state >= 1000 && id(my_t2_x).state <= 2600 && id(my_t2_y).state >= 1000 && id(my_t2_y).state <= 2000) || |
| 261 | + (id(my_t3_x).state >= 1000 && id(my_t3_x).state <= 2600 && id(my_t3_y).state >= 1000 && id(my_t3_y).state <= 2000) |
| 262 | + ); |
| 263 | + on_press: |
| 264 | + - http_request.send: |
| 265 | + url: "https://api.smartthings.com/v1/devices/YOUR_DEVICE_ID_2/commands" |
| 266 | + method: POST |
| 267 | + request_headers: |
| 268 | + Authorization: !lambda return id(st_access_token).c_str(); |
| 269 | + Content-Type: "application/json" |
| 270 | + body: '{"commands": [{"component": "main", "capability": "switch", "command": "on"}]}' |
| 271 | + on_release: |
| 272 | + - http_request.send: |
| 273 | + url: "https://api.smartthings.com/v1/devices/YOUR_DEVICE_ID_2/commands" |
| 274 | + method: POST |
| 275 | + request_headers: |
| 276 | + Authorization: !lambda return id(st_access_token).c_str(); |
| 277 | + Content-Type: "application/json" |
| 278 | + body: '{"commands": [{"component": "main", "capability": "switch", "command": "off"}]}' |
| 279 | +``` |
| 280 | +
|
| 281 | +--- |
| 282 | +
|
| 283 | +## Part 5: Flash and Verify |
| 284 | +
|
| 285 | +``` |
| 286 | +esphome run your_config.yaml --device 192.168.x.x |
| 287 | +``` |
| 288 | + |
| 289 | +Within 15 seconds of connecting to WiFi, you should see in the logs: |
| 290 | + |
| 291 | +```text |
| 292 | +[oauth] >>> WiFi ready, firing token refresh |
| 293 | +[oauth] >>> Refresh START |
| 294 | +[oauth] >>> HTTP Status: 200 |
| 295 | +[oauth] >>> Access token updated |
| 296 | +[oauth] >>> Refresh token updated and saved to NVS |
| 297 | +``` |
| 298 | + |
| 299 | +Then walk into one of your defined zones. The SmartThings API response will show `content-type: application/json` (not `text/html`) and your light turns on. |
| 300 | + |
| 301 | +If you see `HTTP Request failed; Code: 401` — the access token has expired. A simple reboot fixes it: the `wifi: on_connect` handler will fetch a fresh token automatically. |
| 302 | + |
| 303 | +--- |
| 304 | + |
| 305 | +## How the Token Lifecycle Works |
| 306 | + |
| 307 | +```text |
| 308 | +First flash: initial_value token → refresh → new access + refresh → saved to NVS |
| 309 | +Every 20h: NVS refresh token → refresh → new access + refresh → saved to NVS |
| 310 | +After reboot: NVS token loaded → refresh → new access + refresh → saved to NVS |
| 311 | +``` |
| 312 | + |
| 313 | +After the first flash, the `initial_value` in the YAML is never used again. The NVS token takes over and the chain is fully self-sustaining. |
| 314 | + |
| 315 | +--- |
| 316 | + |
| 317 | +## Troubleshooting |
| 318 | + |
| 319 | +!!! warning "401 on SmartThings API calls" |
| 320 | + The access token has expired. Reboot the device — the `wifi: on_connect` handler will fetch a fresh pair automatically. |
| 321 | + |
| 322 | +!!! warning "400 `invalid_grant` on token refresh" |
| 323 | + The refresh token was invalidated. This can happen if the device was offline for more than 30 days, or if a previous refresh failed mid-way. Re-run the browser OAuth flow from Part 2, update both `initial_value` fields in your YAML, flash once, and the self-sustaining chain resumes from there. |
| 324 | + |
| 325 | +!!! warning "400 `invalid_grant` after router reboot or power outage" |
| 326 | + This is the trickiest failure mode. It happens when the device reconnects to WiFi multiple times in quick succession (e.g. during a router restart), causing two parallel token refresh calls. Both use the same refresh token — one succeeds, one invalidates it. The broken token then gets saved to NVS flash and survives every reboot. |
| 327 | + |
| 328 | + `mode: single` on the script prevents this going forward, but if you already have a broken NVS token, here is the recovery procedure (no USB required): |
| 329 | + |
| 330 | + **Step 1 — Get a fresh refresh token via PowerShell:** |
| 331 | + |
| 332 | + ```powershell |
| 333 | + $b64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("YOUR_CLIENT_ID:YOUR_CLIENT_SECRET")) |
| 334 | + Invoke-RestMethod -Method Post ` |
| 335 | + -Uri "https://api.smartthings.com/oauth/token" ` |
| 336 | + -Headers @{Authorization="Basic $b64"} ` |
| 337 | + -ContentType "application/x-www-form-urlencoded" ` |
| 338 | + -Body "grant_type=refresh_token&refresh_token=LAST_KNOWN_GOOD_REFRESH_TOKEN" |
| 339 | + ``` |
| 340 | + |
| 341 | + If that returns 400 too, use the browser OAuth flow from Part 2 to get a completely fresh pair. |
| 342 | + |
| 343 | + **Step 2 — Flash 1: add an `on_boot` override** (priority 600 runs after NVS load but before WiFi): |
| 344 | + |
| 345 | + ```yaml |
| 346 | + esphome: |
| 347 | + name: ${name} |
| 348 | + on_boot: |
| 349 | + priority: 600.0 |
| 350 | + then: |
| 351 | + - lambda: |- |
| 352 | + id(st_refresh_token) = "YOUR_FRESH_REFRESH_TOKEN"; |
| 353 | + ESP_LOGI("oauth", ">>> Boot: token override active"); |
| 354 | + ``` |
| 355 | + |
| 356 | + Flash OTA and wait for `HTTP Status: 200` and `Refresh token updated: xxxxxxxx-...` in the logs. Note down the full new refresh token. |
| 357 | + |
| 358 | + **Step 3 — Flash 2: remove the override immediately** (while device is still running): Remove the entire `on_boot` block, update `initial_value` for `st_refresh_token` to the token you just noted, keep `restore_value: yes`. Flash OTA again. |
| 359 | + |
| 360 | + The NVS now holds the correct token and the self-sustaining chain resumes. |
| 361 | + |
| 362 | +!!! warning "Token refresh fires but `on_response` never runs" |
| 363 | + You are likely triggering the refresh from `on_boot`. The ESP-IDF HTTP stack is not ready at that point. Move to `wifi: on_connect` with a 15-second delay as shown above. |
| 364 | + |
| 365 | +!!! warning "POST body is not being sent" |
| 366 | + Make sure you use `body: !lambda |- return std::string("...");` — plain YAML string values for `body:` are silently dropped by the ESP-IDF HTTP client. |
| 367 | + |
| 368 | +--- |
| 369 | + |
| 370 | +## Adapting for Other Apollo Devices |
| 371 | + |
| 372 | +The OAuth2 token block (globals, script, wifi on_connect, interval) is completely device-agnostic. Swap the `packages:` line for your device and add your own triggers: |
| 373 | + |
| 374 | +- **AIR-1**: trigger a SmartThings ventilation fan when the CO₂ sensor exceeds 1200 ppm |
| 375 | +- **MSR-2**: simple presence-based lighting in smaller rooms |
| 376 | + |
| 377 | +--- |
| 378 | + |
| 379 | +*Tested continuously for 10+ days including multiple reboot cycles. The token chain has not required any manual intervention since initial setup.* |
| 380 | + |
| 381 | +Thanks to Matteo for putting this tutorial together! |
0 commit comments