Back to blog
FILE 0x96·REVERSE ENGINEERING A BLUETOOTH-ONLY IRRIGATION CONTROLLER

Reverse engineering a Bluetooth-only irrigation controller

April 29, 2026 · homelab, ble, automation

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:

  1. Enable "Bluetooth HCI snoop log" in Developer Options on the phone
  2. Force-close and reopen the official app
  3. Send each control action you care about (turn zone 1 on for 1 min, turn it off, turn zone 2 on, etc.)
  4. Pull /sdcard/Android/data/.../btsnoop_hci.log and 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.