Reverse engineering a Bluetooth-only irrigation controller
I have an Orbit B-hyve XD irrigation controller. It's Bluetooth-only —
no Wi-Fi hub built in. Every existing integration (Home Assistant,
pybhyve, bhyve-mqtt) requires Orbit's Gen 2 Wi-Fi hub and the cloud
API. I don't have the hub. I also don't particularly want to pay $40 to
add a cloud dependency to a sprinkler.
What was happening
There's an open Home Assistant feature request for direct BLE control, with zero engineering work done on it. No published BLE protocol. No GATT documentation. Just the official Android app talking directly to the controller over BLE within ~30 feet.
So the question is whether the BLE protocol is decodeable by sitting between the app and the controller and watching what bits move.
What I found
For BLE peripherals that take a single action (turn zone N on for M
minutes), the GATT protocol is almost always trivial: a single
writeable characteristic that takes a small command packet. The packet
is usually [opcode][zone][duration] or close to it. CRCs are common
but not always present. Authentication, when it exists, is usually a
short pairing handshake done once and then forgotten.
The way to find this out is Android's HCI snoop log:
- Enable "Bluetooth HCI snoop log" in Developer Options on the phone
- Force-close and reopen the official app
- Send each control action you care about (turn zone 1 on for 1 min, turn it off, turn zone 2 on, etc.)
- Pull
/sdcard/Android/data/.../btsnoop_hci.logand open it in Wireshark with the Bluetooth dissector
In Wireshark, filter on btatt.opcode == 0x12 (Write Request) and look
at the value bytes. Send the same action twice and compare. Bytes that
match across two runs of "turn zone 1 on for 1 minute" are the command
shape; bytes that differ are likely counters, timestamps, or session
state.
Once the protocol is decoded, reproducing it with Python's bleak
library is short:
import asyncio
from bleak import BleakClient
ADDR = "AA:BB:CC:DD:EE:FF" # controller MAC
CONTROL_CHAR = "00001234-0000-1000-8000-00805f9b34fb"
async def run_zone(zone: int, minutes: int):
payload = bytes([0x10, zone, minutes, 0x00]) # discovered shape
async with BleakClient(ADDR) as client:
await client.write_gatt_char(CONTROL_CHAR, payload)
asyncio.run(run_zone(1, 5))
The local controller becomes scriptable from any Linux box with a BLE adapter in range. Pair that with an ESP32 in the yard as a bridge and you get cloud-free, hub-free, fully local control.
The fix
(I haven't done the snoop-and-decode work yet — this is the plan I'd follow when I do.)
The interesting part is recognizing that "no public protocol" doesn't mean "no decodable protocol." A consumer device that talks BLE to a phone is, by definition, talking a documented-enough protocol that a phone app can use it without OEM secret sauce. The "secret" is in the app. Snoop the app, read the bits, write the Python.
What I'd do differently
I'd reach for HCI snoop logs before searching for an integration. There is an established pattern of "Home Assistant integration exists for the Wi-Fi version but the BLE version is ignored." For a device class where the BLE protocol is likely trivial — irrigation, smart plugs, simple locks, lighting controllers — decoding the protocol is often a smaller project than figuring out which cloud API endpoints the official integration is using.