Unattended BBQ monitoring for ThermoPro Bluetooth thermometers. Runs on a Mac or a Raspberry Pi, alerts your phone via Telegram, and tells you when the meat will actually be ready.
Not affiliated with, endorsed by, or connected to ThermoPro. This is an independent project that talks to their hardware over Bluetooth.
The phone app is great until you want to leave the house. This runs on a machine that stays near the grill and pushes to a phone that doesn't.
Thermometer ──BLE──> Mac or Pi ──┬─> Telegram alerts + commands, anywhere
├─> menu bar live temps on macOS
├─> browser http://<host>:8787/
└─> SQLite every reading, for later
- Alerts that don't cry wolf. Opening the lid drops a pit 50 °C in a minute. Naive monitoring reads that as a dying fire and wakes you at 3am. This doesn't.
- A dead-man switch. The failure that ruins an overnight cook isn't a missed threshold, it's silence — the process dies and no news reads as good news. A heartbeat says "still alive, here are the numbers", so silence is abnormal.
- Time estimates that survive the stall. Linear extrapolation says six hours mid-stall and forty minutes an hour later. This fits the actual heat transfer.
- Remote commands.
/temp,/status,/eta, change thresholds, all from your phone.
Works with ThermoPro BBQ thermometers using the FFF0 BLE protocol — the
multi-probe wireless models. Confirmed on TP25 / TP25W / TP902 / TP920 /
TP960, and anything else advertising service
1086FFF0-3343-4817-8BB2-B32206336CE8.
Run ./setup.sh — if it finds your thermometer, you're supported.
You'll also need a Mac (macOS 14+) or a Raspberry Pi with Bluetooth, within about 10 m of the grill.
git clone https://github.com/YOUR_USER/ThermoWatch.git
cd ThermoWatch
./setup.shSetup scans for your thermometer, connects, and shows you live readings from each port so you can say which is the meat and which is the air — no need to know your port numbers, and the wire's numbering doesn't always match the app's.
Live probes:
Port 2: 22.3 C
Port 4: 135.2 C
Which port is in the grill (air temperature)? [4]
Which port is in the meat? [2]
What are you cooking? [brisket]
Then:
./run.shQuit the phone app first. The thermometer accepts exactly one Bluetooth connection and won't even advertise while the app holds it. See below.
The thermometer allows a single BLE connection, first-come-first-served. Whoever has the link keeps it; a second client is refused rather than allowed to take over.
- Quit the phone app before starting ThermoWatch, or it will sit there scanning and finding nothing.
- Once ThermoWatch is running, opening the phone app is harmless — it simply won't connect. Your monitoring keeps going.
- To use the phone app again, stop ThermoWatch first.
This is the device's behaviour, not a limitation of this code.
This is what lets you leave. Outbound HTTPS only — no port forwarding, no exposed service, no cloud account.
- Message @BotFather, send
/newbot, follow the prompts - Copy the token it gives you
- Open your new bot and press Start (bots can't message you first)
- Paste the token when
./setup.shasks
| Command | What it does |
|---|---|
/temp |
current temperatures |
/status |
temperatures, trend, battery, link state, estimates |
/eta |
time to the wrap point and the finishing temperature |
/wrapped |
tell it you've wrapped — ends the stall in the estimate |
/set Pit high 160 |
change a threshold live |
/set Pit falling off |
clear a threshold |
/mute 60 |
silence alerts for an hour (heartbeat continues) |
/help |
list commands |
There's no fire control here. A kettle has no actuator, so "remote control" means querying and changing alerts, not adjusting vents.
cd menubar && ./scripts/build.sh
cp -R build/ThermoWatch.app /Applications/
open /Applications/ThermoWatch.appShows 🔥 72° · 131° in your menu bar, with per-probe detail, trend and battery
in the dropdown.
Point it at wherever the monitor is running:
# monitor on this same Mac
defaults write com.thermowatch.menubar statusPath ~/ThermoWatch/data/status.json
# monitor on a Pi (needs enabled = true under [http])
defaults write com.thermowatch.menubar statusPath http://192.168.1.50:8787/status.jsonIt never shows a number it can't vouch for. A frozen-but-plausible temperature is worse than an obvious error, so it distinguishes:
| Display | Meaning |
|---|---|
flame + 72° · 131° |
live |
⚠ + — "no data for 3m" |
monitor running, Bluetooth link dropped |
⚠ + — "monitor not running" |
the monitor process itself is gone |
⚠ + — |
connected, but no probe is reporting |
The app deliberately does not speak Bluetooth — it would fight the monitor for the thermometer's single connection.
With enabled = true under [http], visit http://<host>:8787/ from any device
on your network. Big digits, auto-refreshing, greys out when data goes stale.
Works fine on a phone.
Better than a laptop: always on, no sleep to fight, and it can live closer to the grill.
git clone https://github.com/YOUR_USER/ThermoWatch.git ~/ThermoWatch
cd ~/ThermoWatch && ./setup.shThen install the service — no root needed if user lingering is on
(loginctl show-user $USER --property=Linger; enable with
sudo loginctl enable-linger $USER):
mkdir -p ~/.config/systemd/user
sed "s|/home/YOUR_USER|$HOME|g; s|AA:BB:CC:DD:EE:FF|$(grep '^address' config.toml | cut -d'"' -f2)|" \
systemd/thermowatch.service > ~/.config/systemd/user/thermowatch.service
systemctl --user daemon-reload
systemctl --user enable --now thermowatchCheck on it:
systemctl --user status thermowatch
tail -f ~/ThermoWatch/data/monitor.logSignal strength matters more than you'd think. Below about −80 dBm a scan still
finds the device but a sustained connection drops repeatedly. Aim for −75 dBm or
better; ./setup.sh warns you if it's weak.
caffeinate prevents idle sleep, but closing the lid still sleeps the machine
— that's a separate mechanism. For an overnight cook, lid open and plugged in:
caffeinate -dimsu ./run.shHonestly, use a Pi.
Threshold alarms are the easy part. Everything below exists because a real cook broke a simpler version.
- Silence is the enemy. A heartbeat every 30 minutes, plus an alert if no reading arrives for 3 minutes. The dead-man is driven by frame arrival, not connection state — a BLE link can sit there looking healthy while notifications have quietly stopped.
- Lid-opens are not dying fires. Thermal mass means a dying fire cannot drop a pit tens of degrees in a couple of minutes; only opening the lid (or spritzing the probe) can. Drops that steep are treated as disturbances, and the downward alerts pause while the pit recovers. It's a grace period, not an amnesty — still cold when it expires, and it alerts.
- A gap in the data is also a disturbance. Nothing was recorded, so nothing about the change can be attributed.
- Declines are judged by where they're heading, not by slope. A pit falling 0.8 °C/min at 150 °C is fine; the same slope at 110 °C is not. It alerts when a fall is projected to breach your floor, and says when.
- Thresholds latch with hysteresis. A pit parked on its limit is one event, not one alert per cooldown, forever.
- Empty ports stay quiet; probes that fall out don't. A slot that never reported is just an unused socket. A slot that was reading and stops means a probe came out of the meat.
/eta fits Newton's law of cooling — d(meat)/dt = k · (pit − meat) — to your
actual data, then simulates forward.
Validated against a real 6-hour brisket: fitting k on the first 45 minutes
predicted the next hour to within 1 °C. k held between 0.0047 and 0.0061
while the raw rate halved, which is why rate-based extrapolation misleads and
this doesn't.
The stall is detected as k collapsing to about a third of its fitted value —
a physical parameter changing, rather than a flat line being guessed at.
Estimates are ranges, because a stall lasting 1–4 hours doesn't support a single number, and they state the pit temperature they assume:
wrap point (75C): 8m-13m
target (95C): 72m-2.2h
phase: in the stall, assumes pit holds 133C
Wrapping ends the stall by stopping evaporative cooling, and can't be inferred
reliably — so tell it with /wrapped.
Set profile on the meat probe: brisket, pork_butt, beef_short_rib,
chicken, turkey, pork_loin, lamb_leg, beef_joint.
Where a target is a food-safety figure (poultry, pork) it's stated as such.
Where doneness is a preference (lamb, beef joints) the profile says so rather
than implying an authority it doesn't have. Override with final_target.
Nothing found when scanning. The phone app is almost certainly connected. Force-quit it — backgrounding isn't enough on iOS — and scan again.
It found the device but won't connect, or reconnects forever. A killed
process can leave the Bluetooth link open on Linux; the thermometer then stops
advertising because it thinks it's still connected. ThermoWatch falls back to
connecting by address, and the systemd unit clears the link on start and stop.
To fix by hand: bluetoothctl disconnect <MAC>.
Menu bar app shows a warning triangle. It can't reach the monitor. Check the
monitor is running, and that statusPath points at the right place. For an HTTP
path, check the firewall (below).
Can't reach http://<pi>:8787/ from another machine. Raspberry Pi OS may run
ufw. Note ufw lives in /usr/sbin, which isn't in a non-login SSH PATH, so
command -v ufw misleadingly reports nothing — use systemctl is-active ufw.
Open it to your LAN only:
sudo ufw allow from 192.168.1.0/24 to any port 8787 proto tcpmacOS blocks the HTTP connection. App Transport Security blocks plain HTTP.
The bundled Info.plist carries NSAllowsLocalNetworking, which permits local
destinations only — if you rebuilt it and dropped that, put it back.
Temperatures look wrong / the wrong probe is named. Re-run ./setup.sh; it
shows live readings per port so you can reassign them.
The address in config doesn't work on another machine. macOS reports a per-machine UUID rather than a MAC address, so a macOS address is meaningless on a Pi and vice versa. Re-run setup on the new machine.
Everything lands in data/cook.db (SQLite):
frames— timestamp, battery, raw hex, so a cook can be re-decoded if any decoding assumption later turns out to be wrongsamples— per-probe temperature or sentinel stateevents— alerts and heartbeats
Finished cooks are archived to data/archive/cook-YYYY-MM-DD-<profile>.db and
each new cook starts clean. The trigger is a gap in the data, not process
startup — the service restarts itself on crash, and splitting a cook's history
would break the trend and estimate calculations.
Reverse-engineered by others, re-validated live. See thermowatch/protocol.py.
- Service
1086FFF0-3343-4817-8BB2-B32206336CE8 - Write a handshake to
FFF1, then frames stream fromFFF2about every 1.5s. Without the handshake, nothing streams. - Command
0x30carries battery and four probe slots, two bytes each, BCD-packed as hundreds/tens/ones/tenths with sign in the top bit - Sentinels:
FFFFno probe,DDDDerror,EEEEover range — states, not temperatures, and never treated as numbers - The wire unit is Celsius regardless of what the phone app displays
Prior art, without which this would have taken far longer: thermopro-cli and ha-thermopro-meat.
- Control the fire. No actuator, no vent servo, no blower.
- Work beyond Bluetooth range. The host machine must be near the grill.
- Run on Windows.
bleaksupports it; nobody has tried. - Replace judgement. Brisket is done when it's probe-tender, not at 95 °C.
venv/bin/python -m pytestThe decoder tests run against a frame captured off real hardware. The alert tests replay 30 minutes of an actual cook — including two lid-openings and a vinegar spritz that dropped the pit to 48 °C — and assert that none of it produces a false alarm, while a genuine decline still does.
That fixture found three separate bugs that synthetic test data did not.
MIT — see LICENSE.