Implement key-seed auth handshake (service 0010)
Root cause of immediate disconnect after write: panel requires auth on service 0010 before accepting commands on 0030. Protocol found by decompiling Plugin.BLE.dll (BleDeviceUnlockManager.PerformKeySeedExchange). Auth flow: 1. Read 4-byte seed from char 0012 (00000012-0200-a58e-e411-afe28044e62c) 2. Apply modified TEA with RV-specific cypher (612643285 / 0x248431D5) 3. Write 4-byte little-endian result to char 0013 4. Retries up to 3x if seed < 4 bytes (panel not yet ready) connect() now calls perform_auth() before enabling 0034 notifications. Also cleans up experimental auth attempts from campsite debugging session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,74 @@ from cobs_protocol import CobsEncoder, CobsDecoder
|
|||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth / Key-Seed Exchange (service 0010)
|
||||||
|
# Reverse-engineered from Plugin.BLE.dll :: BleDeviceUnlockManager
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTH_SERVICE = "00000010-0200-a58e-e411-afe28044e62c"
|
||||||
|
SEED_CHAR = "00000012-0200-a58e-e411-afe28044e62c" # read seed from here
|
||||||
|
KEY_CHAR = "00000013-0200-a58e-e411-afe28044e62c" # write response here
|
||||||
|
RV_LINK_CYPHER = 612643285 # MyRvLinkBleGatewayScanResult.RvLinkKeySeedCypher
|
||||||
|
|
||||||
|
|
||||||
|
def _tea_encrypt(cypher: int, seed: int) -> int:
|
||||||
|
"""Modified TEA used by Lippert OneControl for BLE auth."""
|
||||||
|
DELTA = 2654435769 # 0x9E3779B9
|
||||||
|
num = DELTA
|
||||||
|
for _ in range(32):
|
||||||
|
seed = (seed + (((cypher << 4) + 1131376761) ^ (cypher + num) ^ ((cypher >> 5) + 1919510376))) & 0xFFFFFFFF
|
||||||
|
cypher = (cypher + (((seed << 4) + 1948272964) ^ (seed + num) ^ ((seed >> 5) + 1400073827))) & 0xFFFFFFFF
|
||||||
|
num = (num + DELTA) & 0xFFFFFFFF
|
||||||
|
return seed
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_auth(client: BleakClient) -> bool:
|
||||||
|
"""
|
||||||
|
Perform key-seed exchange on service 0010 before any commands on 0030.
|
||||||
|
Returns True on success, False on failure.
|
||||||
|
"""
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
seed_bytes = await client.read_gatt_char(SEED_CHAR)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Auth: failed to read seed (attempt {attempt+1}): {e}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If device returns "unlocked" it's already authenticated
|
||||||
|
if len(seed_bytes) == len(b"unlocked") and seed_bytes.lower() == b"unlocked":
|
||||||
|
print("Auth: already unlocked")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if len(seed_bytes) < 4:
|
||||||
|
print(f"Auth: seed too short ({len(seed_bytes)} bytes), retrying...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
seed = struct.unpack_from("<I", seed_bytes, 0)[0]
|
||||||
|
if seed == 0:
|
||||||
|
print("Auth: seed is zero, retrying...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = _tea_encrypt(RV_LINK_CYPHER, seed)
|
||||||
|
key_bytes = struct.pack("<I", key)
|
||||||
|
print(f"Auth: seed=0x{seed:08x} → key=0x{key:08x}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.write_gatt_char(KEY_CHAR, key_bytes)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
print("Auth: key written, handshake complete")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Auth: failed to write key (attempt {attempt+1}): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("Auth: all attempts exhausted")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class CommandType(IntEnum):
|
class CommandType(IntEnum):
|
||||||
GET_DEVICES = 1
|
GET_DEVICES = 1
|
||||||
ACTION_SWITCH = 64
|
ACTION_SWITCH = 64
|
||||||
@@ -44,9 +112,12 @@ class OneControlClient:
|
|||||||
self._pending_commands = {} # seq -> cmd_type
|
self._pending_commands = {} # seq -> cmd_type
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Connect to OneControl device"""
|
"""Connect to OneControl device and perform key-seed auth handshake."""
|
||||||
self.client = BleakClient(self.address)
|
self.client = BleakClient(self.address)
|
||||||
await self.client.connect()
|
await self.client.connect(timeout=20.0)
|
||||||
|
if not await perform_auth(self.client):
|
||||||
|
await self.client.disconnect()
|
||||||
|
raise RuntimeError("Auth handshake failed")
|
||||||
await self.client.start_notify(self.READ_CHAR, self._notification_handler)
|
await self.client.start_notify(self.READ_CHAR, self._notification_handler)
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self):
|
||||||
@@ -168,7 +239,7 @@ async def scan_for_onecontrol():
|
|||||||
|
|
||||||
found = []
|
found = []
|
||||||
for address, (device, adv) in devices.items():
|
for address, (device, adv) in devices.items():
|
||||||
if device.name and "OneControl" in device.name:
|
if device.name and "LCIRemote" in device.name:
|
||||||
print(f"Found by name: {device.name} ({address})")
|
print(f"Found by name: {device.name} ({address})")
|
||||||
found.append(device)
|
found.append(device)
|
||||||
elif any("00000030-0200" in uuid.lower() for uuid in adv.service_uuids):
|
elif any("00000030-0200" in uuid.lower() for uuid in adv.service_uuids):
|
||||||
@@ -192,12 +263,9 @@ async def main():
|
|||||||
print(f" {d.name} ({d.address})")
|
print(f" {d.name} ({d.address})")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# No device found via scan — set address manually if you already know it
|
ADDRESS = "30:C6:F7:E0:80:8E"
|
||||||
ADDRESS = "XX:XX:XX:XX:XX:XX"
|
|
||||||
if ADDRESS == "XX:XX:XX:XX:XX:XX":
|
|
||||||
print("No device found. Set ADDRESS manually if you know the BT address.")
|
|
||||||
return
|
|
||||||
address = ADDRESS
|
address = ADDRESS
|
||||||
|
print(f"No device found via scan, trying known address {address}")
|
||||||
|
|
||||||
client = OneControlClient(address)
|
client = OneControlClient(address)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user