Skip to content

Commit 3de4040

Browse files
committed
docs: add SmartThings direct control tutorial via OAuth2
Adds a community-contributed tutorial (by Matteo) for controlling SmartThings devices directly from Apollo ESPHome sensors using OAuth2 — no Home Assistant or hub required. Covers token lifecycle, zone-based presence detection, NVS persistence, and troubleshooting. Also adds Samsung SmartThings to the Supported Platforms page with a link to the new tutorial.
1 parent af9278d commit 3de4040

3 files changed

Lines changed: 387 additions & 0 deletions

File tree

docs/products/general/supported-platforms.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ At Apollo, we are actively working on adding support for every smart home ecosys
4141
* **Integration:** Fully supported via this [unofficial adapter](https://github.com/DrozmotiX/ioBroker.esphome).
4242
* **Support:** We do not provide support for this platform but if enough users want it that could change. We of course always support the hardware of our products though!
4343

44+
## Samsung SmartThings
45+
46+
* **Integration:** Supported via direct OAuth2 from your Apollo ESPHome device — no hub or Home Assistant required. See our [SmartThings Direct Control tutorial](tutorials/smartthings-direct-control.md) for full setup instructions including zone-based presence detection and autonomous token renewal.
47+
* **Support:** We do not provide official support for this platform, but the community tutorial covers the full setup including token renewal and troubleshooting.
48+
4449
## MQTT
4550

4651
* **Integration:** Fully supported via a custom yaml edit for your device where you fill in the server IP/hostname, mqtt username, and mqtt password.
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)