OneControl BLE — First Live Session

April 3–4, 2026

Back in December I spent an evening decompiling the Lippert Connect Android app to extract the Bluetooth protocol for their OneControl RV control panel. The idea is eventually to wire this into Home Assistant running on a Pi 4 in the RV cabinet, so I can control the lights, water pump, awning, and so on without opening a phone app.

The December work got me to a Python client that looked correct on paper. This was the first session at the actual hardware — a 2023 Forest River Chateau 22QB.

It did not work on the first try. It did not work on the second or third try either. Here’s what was actually wrong.


Background: How the Protocol Was Extracted

The Lippert Connect app is a Xamarin app — C# compiled for Android. The assemblies are stored in XABA v2.2 format inside an ELF shared object: 431 .NET DLLs packed with LZ4 compression. Standard Xamarin extraction tools (Dexamarin, pyxamstore) don’t support this format, so I (mostly Claude, ain’t nobody got time for that) wrote a custom extractor that reversed the block structure from first principles.

From there, ilspycmd decompiles the .NET assemblies back to readable C# source. The BLE protocol is implemented across four DLLs: OneControl.Direct.MyRvLinkBle.dll, OneControl.Direct.MyRvLink.dll, IDS.Portable.Common.dll, and Plugin.BLE.dll.

The protocol uses COBS encoding with a CRC8 checksum. Commands go to a write characteristic; responses and status broadcasts come back as GATT notifications. Straightforward enough — in theory.


Bug 1: There’s an Auth Handshake on a Separate Service

Symptom: The panel accepted the BLE connection, then dropped it immediately when we wrote the first command to service 0030.

Cause: I had missed an entire service. Before service 0030 accepts anything, you have to complete a challenge-response exchange on service 0010. The panel sends a 4-byte seed; you compute a response key and write it back; the panel returns the ASCII string "Unlocked" and only then starts listening on 0030.

This isn’t documented anywhere and it’s not obvious from the service/characteristic UUIDs. I found it by decompiling Plugin.BLE.dll with ilspycmd and grepping for the seed characteristic GUID — it turned up in BleDeviceUnlockManager.PerformKeySeedExchange.

The cipher: The key derivation uses a modified TEA (Tiny Encryption Algorithm) — 32 rounds, standard delta 0x9E3779B9, but with four custom magic constants replacing the usual key schedule. The cypher constant 612643285 lives in MyRvLinkBleGatewayScanResult.RvLinkKeySeedCypher.

The C# bug I had to replicate: TEA does arithmetic on 32-bit unsigned integers. C# uint wraps automatically. Python integers don’t. The original Python translation only masked the final result to 32 bits — each intermediate (shift + constant) also needs masking, or you get a different result. Both seed and key are big-endian ((Endian)0 = Endian.Big in the IDS enum).

After fixing this, the panel returned bytearray(b'Unlocked') and stayed connected.


Bug 2: Wrong CRC8

Symptom: Every notification from the panel failed CRC validation. Example: received 0xaf != calculated 0x35.

Cause: The Python client used a bit-by-bit CRC8 implementation (poly=0x07, init=0x55). The C# source in Crc8.cs uses a precomputed 256-entry lookup table with the same init value. These two approaches should produce the same results for the same polynomial — but the lookup table in Crc8.cs uses a different polynomial than 0x07. The table isn’t derivable from any standard CRC8 variant; it’s specific to this implementation.

Fix: Copied the exact 256-entry table from Crc8.cs verbatim.


Bug 3: Sequence Number Endianness

Symptom: The panel wasn’t matching responses to commands. Decoded packets had seq values that didn’t correspond to anything we’d sent.

Cause: The sequence number field in both outgoing commands and incoming responses is big-endian. The original translation used little-endian (struct.pack("<H", ...) / struct.unpack("<H", ...)).

This is easy to miss because the C# source uses a GetValueUInt16(buffer, offset, (Endian)0) helper and you have to know that (Endian)0 = Endian.Big from the enum definition in a separate assembly.


What the Protocol Actually Looks Like

After fixing all three bugs, everything worked on the first attempt.

Outgoing command (before COBS encoding):

[Seq (2 bytes, BE)] [CommandType (1)] [DeviceTableId (1)] [Payload...]

CRC8 is appended by the encoder, not the caller.

Incoming notification:

[EventType (1)] [Seq (2 bytes, BE)] [ResponseType (1)] [ExtendedData...]

The COBS variant: Not standard COBS. Lippert’s encoder packs zero bytes into the code byte rather than emitting them inline: code_byte = data_count + (zero_count × 64). The decoder is stateful — it processes bytes one at a time and uses the upper bits of the running code byte to know how many zeros to insert when the lower 6 bits reach zero.

Unsolicited broadcasts: The panel pushes state immediately on connect and periodically thereafter. Useful ones:

EventContent
RelayBasicLatchingType2 (6)ON/OFF state per switch device
RvStatus (7)Battery voltage (uint16 BE / 256.0), external temp
TankSensorStatus (12)Fill level % per tank
RelayHBridgeMomentaryType2 (14)Slide/awning movement state

Device Mapping

The GetDevices command returns all devices with their protocol type and instance number. The response comes back as multiple packets — intermediate packets (resp=0x01) carry batches of device entries; the final packet (resp=0x81) is just a CRC and count.

Mapping device IDs to physical components was done by connecting and watching the status broadcasts to see which devices were currently ON, then toggling things physically and reconnecting.

DevIDComponent
2slide or awning (unconfirmed which)
3slide or awning (unconfirmed which)
4water pump
5gas water heater
6exterior lights
7interior lights
8grey tank 2
9grey tank 1
10black tank
11fresh water tank

Devices 8–11 are read-only — they have device type 0x0a in the device list but only report state through TankSensorStatus broadcasts; they don’t accept ActionSwitch commands.

The panel has a ~30 second idle timeout, so the client sends a GetDevices keepalive every 20 seconds.

One thing I checked: the panel has a physical LED labeled “DSI fault” (direct spark ignition — the propane ignition system). That indicator does not appear anywhere in the BLE protocol. It’s a hardware-level signal only.


State of the Code

src/
  cobs_protocol.py    — encoder, decoder, CRC8 lookup table
  onecontrol_client.py — BLE client, auth, commands, status parsing
  scratch.py          — interactive testing (gitignored)

The client currently runs as a live monitor — connects, authenticates, and streams human-readable state updates:

[Battery] 13.34V
[Tank] grey tank 2: 33%
[Tank] grey tank 1: 0%
[Tank] black tank: 33%
[Tank] fresh water tank: 0%
[Switch] interior lights: ON
[Switch] exterior lights: ON
[Switch] gas water heater: ON
[Switch] water pump: OFF

And commands work:

await client.turn_on_light(7)   # interior lights come on
await client.turn_off_light(6)  # exterior lights go off

Next

Build the Home Assistant custom component. The Python client is essentially the core of it — it needs wrapping in the HA entity model: switch for devices 4–7, sensor for tanks and voltage, cover for the slides/awnings. The Pi 4 running HAOS will live in the same cabinet as the OneControl module, so BLE range isn’t a concern.

Wesley Ray · blog · git · resume