Compare commits

...
2 Commits
Author SHA1 Message Date
wesandClaude Opus 4.8 34155fd7f9 Add HA custom integration; fix bleak 3.x write-without-response regression
The OneControl panel's command characteristic is a streaming (Write Without
Response) endpoint. Bleak 3.x changed write_gatt_char to default to
write-with-response when the char advertises the "write" property, so every
command (incl. switch turn_on/off) got rejected by the panel with ATT 0x0E
(Unlikely Error), surfaced as BleakGATTProtocolError. Force response=False on
the command write (matching the auth key write) to restore control.

Also commits the productionized custom_components integration (config flow,
coordinator, switch/sensor/cover entities, key-seed TEA auth, COBS codec) and
the matching src/ RE client/COBS fixes (big-endian framing, table-driven CRC8,
status-event decoding) that were developed but never tracked.

Verified live on the campsite HAOS Pi: switch.exterior_lights / interior_lights
toggle the physical panel with no GATT error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 06:41:14 -04:00
wesandClaude Sonnet 4.6 b6d498c829 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>
2026-04-03 19:56:36 -04:00
14 changed files with 1273 additions and 127 deletions
@@ -0,0 +1,33 @@
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import OneControlCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.COVER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
address = entry.data["address"]
coordinator = OneControlCoordinator(hass, address)
try:
await coordinator.async_connect()
except Exception as e:
_LOGGER.error("Failed to connect to OneControl at %s: %s", address, e)
return False
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator: OneControlCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_disconnect()
return unload_ok
@@ -0,0 +1,149 @@
# cobs_protocol.py
# COBS encoding/decoding and CRC8 implementation for Lippert OneControl
# Based on decompiled source from IDS.Portable.Common.COBS/CobsEncoder.cs and Crc8.cs
class Crc8:
"""CRC8 using the exact lookup table from IDS.Portable.Common/Crc8.cs (init=0x55)."""
_TABLE = [
0, 94, 188, 226, 97, 63, 221, 131, 194, 156,
126, 32, 163, 253, 31, 65, 157, 195, 33, 127,
252, 162, 64, 30, 95, 1, 227, 189, 62, 96,
130, 220, 35, 125, 159, 193, 66, 28, 254, 160,
225, 191, 93, 3, 128, 222, 60, 98, 190, 224,
2, 92, 223, 129, 99, 61, 124, 34, 192, 158,
29, 67, 161, 255, 70, 24, 250, 164, 39, 121,
155, 197, 132, 218, 56, 102, 229, 187, 89, 7,
219, 133, 103, 57, 186, 228, 6, 88, 25, 71,
165, 251, 120, 38, 196, 154, 101, 59, 217, 135,
4, 90, 184, 230, 167, 249, 27, 69, 198, 152,
122, 36, 248, 166, 68, 26, 153, 199, 37, 123,
58, 100, 134, 216, 91, 5, 231, 185, 140, 210,
48, 110, 237, 179, 81, 15, 78, 16, 242, 172,
47, 113, 147, 205, 17, 79, 173, 243, 112, 46,
204, 146, 211, 141, 111, 49, 178, 236, 14, 80,
175, 241, 19, 77, 206, 144, 114, 44, 109, 51,
209, 143, 12, 82, 176, 238, 50, 108, 142, 208,
83, 13, 239, 177, 240, 174, 76, 18, 145, 207,
45, 115, 202, 148, 118, 40, 171, 245, 23, 73,
8, 86, 180, 234, 105, 55, 213, 139, 87, 9,
235, 181, 54, 104, 138, 212, 149, 203, 41, 119,
244, 170, 72, 22, 233, 183, 85, 11, 136, 214,
52, 106, 43, 117, 151, 201, 74, 20, 246, 168,
116, 42, 200, 150, 21, 75, 169, 247, 182, 232,
10, 84, 215, 137, 107, 53,
]
RESET_VALUE = 0x55
@staticmethod
def calculate(data: bytes) -> int:
crc = Crc8.RESET_VALUE
for b in data:
crc = Crc8._TABLE[(crc ^ b) & 0xFF]
return crc
class CobsEncoder:
"""COBS Encoder matching CobsEncoder.cs (numDataBits=6, prependStartFrame=true, useCrc=true).
Code byte format: num_data_bytes + (num_consecutive_zeros * 64)
This differs from standard COBS — zeros are packed into the code byte, not output inline.
"""
LSB = 1 << 6 # 64 (FrameByteCountLsb)
MAX_DATA = LSB - 1 # 63 (MaxDataBytes)
MAX_ZEROS = 255 - MAX_DATA # 192 (MaxCompressedFrameBytes)
def __init__(self):
self.frame_byte = 0x00
def encode(self, source: bytes) -> bytes:
"""Encode source bytes with CRC8 appended, using Lippert COBS variant."""
crc = Crc8.calculate(source)
data = source + bytes([crc])
output = bytearray([self.frame_byte]) # Start frame
i = 0
while i < len(data):
code_index = len(output)
output.append(0) # Placeholder for code byte
num6 = 0
# Collect non-zero data bytes (up to MAX_DATA)
while i < len(data) and num6 < self.MAX_DATA:
b = data[i]
if b == self.frame_byte:
break
output.append(b)
num6 += 1
i += 1
# Collect consecutive zero bytes, packing each as +LSB in the code byte
while i < len(data):
if data[i] != self.frame_byte:
break
num6 += self.LSB
i += 1
if num6 >= self.MAX_ZEROS:
break
output[code_index] = num6
output.append(self.frame_byte) # End frame
return bytes(output)
class CobsDecoder:
"""COBS Decoder — stateful byte-at-a-time algorithm matching CobsDecoder.cs.
Code byte encoding (numDataBits=6):
code_byte = (num_zeros_to_insert << 6) | num_data_bytes_before_trigger
When the lower 6 bits of the running code_byte reach 0, the upper bits
tell how many zero bytes to insert. This differs from standard COBS.
"""
NUM_DATA_BITS = 6
LSB = 1 << NUM_DATA_BITS # 64
MAX_DATA_MASK = LSB - 1 # 63 (0x3F)
def __init__(self):
self.frame_byte = 0x00
def decode(self, encoded: bytes) -> bytes:
"""Decode one complete COBS packet (start frame … end frame)."""
output = bytearray()
code_byte = 0
for b in encoded:
if b == self.frame_byte:
# Frame byte — either start (no data yet) or end (complete packet)
if code_byte != 0 or len(output) == 0:
# Start frame or error: reset state
output.clear()
code_byte = 0
continue
# End frame: strip CRC and return
received_crc = output[-1]
data = output[:-1]
calc_crc = Crc8.calculate(data)
if received_crc != calc_crc:
raise ValueError(f"CRC mismatch: received 0x{received_crc:02x} != calculated 0x{calc_crc:02x}")
return bytes(data)
if code_byte <= 0:
# Code byte: encodes data-count (low 6 bits) + zero-count (high bits)
code_byte = b
else:
# Data byte
code_byte -= 1
output.append(b)
# When lower NUM_DATA_BITS of code_byte hit zero, insert implied zeros
if (code_byte & self.MAX_DATA_MASK) == 0:
while code_byte > 0:
output.append(self.frame_byte)
code_byte -= self.LSB
raise ValueError("Incomplete COBS packet — no end frame received")
@@ -0,0 +1,304 @@
# onecontrol_client.py
# Lippert OneControl BLE Client
# Based on reverse engineered protocol from decompiled Xamarin app
import asyncio
import logging
import struct
from collections.abc import Callable
from bleak import BleakClient
from bleak_retry_connector import establish_connection, BleakClientWithServiceCache
from .cobs_protocol import CobsEncoder, CobsDecoder
from enum import IntEnum, StrEnum
class EventType(StrEnum):
SWITCH = "switch"
TANK = "tank"
BATTERY = "battery"
COVER_RAW = "cover_raw"
_LOGGER = logging.getLogger(__name__)
AUTH_SERVICE = "00000010-0200-a58e-e411-afe28044e62c"
SEED_CHAR = "00000012-0200-a58e-e411-afe28044e62c"
KEY_CHAR = "00000013-0200-a58e-e411-afe28044e62c"
RV_LINK_CYPHER = 612643285
def _tea_encrypt(cypher: int, seed: int) -> int:
M = 0xFFFFFFFF
DELTA = 2654435769
num = DELTA
for _ in range(32):
t = (((cypher << 4) & M) + 1131376761) & M
t ^= (cypher + num) & M
t ^= ((cypher >> 5) + 1919510376) & M
seed = (seed + t) & M
t = (((seed << 4) & M) + 1948272964) & M
t ^= (seed + num) & M
t ^= ((seed >> 5) + 1400073827) & M
cypher = (cypher + t) & M
num = (num + DELTA) & M
return seed
async def _perform_auth(client: BleakClient) -> bool:
for attempt in range(3):
if not client.is_connected:
_LOGGER.warning("Auth: connection lost before attempt %d", attempt + 1)
return False
try:
seed_bytes = await client.read_gatt_char(SEED_CHAR)
except Exception as e:
_LOGGER.warning("Auth: failed to read seed (attempt %d): %s", attempt + 1, e)
if not client.is_connected:
return False
await asyncio.sleep(2)
continue
if seed_bytes.lower() == b"unlocked":
_LOGGER.debug("Auth: already unlocked")
return True
if len(seed_bytes) < 4:
_LOGGER.warning("Auth: seed too short (%d bytes), retrying", len(seed_bytes))
await asyncio.sleep(2)
continue
seed = struct.unpack_from(">I", seed_bytes, 0)[0]
if seed == 0:
_LOGGER.warning("Auth: seed is zero, retrying")
await asyncio.sleep(2)
continue
key = _tea_encrypt(RV_LINK_CYPHER, seed)
_LOGGER.debug("Auth: seed=0x%08x → key=0x%08x", seed, key)
try:
await client.write_gatt_char(KEY_CHAR, struct.pack(">I", key), response=False)
await asyncio.sleep(0.5)
verify = await client.read_gatt_char(SEED_CHAR)
if verify.lower() == b"unlocked":
_LOGGER.debug("Auth: confirmed unlocked")
return True
_LOGGER.warning("Auth: unexpected verify response: %r", verify)
return True # key was accepted even if verify string differs
except Exception as e:
_LOGGER.error("Auth: failed to write key (attempt %d): %s", attempt + 1, e)
return False
_LOGGER.error("Auth: all attempts exhausted")
return False
class CommandType(IntEnum):
GET_DEVICES = 1
ACTION_SWITCH = 64
ACTION_MOVEMENT = 65
ACTION_DIMMABLE = 67
ACTION_RGB = 68
ACTION_HVAC = 69
class SwitchState(IntEnum):
OFF = 0
ON = 1
TOGGLE = 2
class MovementState(IntEnum):
RETRACT = 0
EXTEND = 1
STOP = 2
# State callback type: (event_type: str, data: dict) -> None
# event_type values: "switch", "tank", "battery", "cover"
StateCallback = Callable[[str, dict], None]
class OneControlClient:
SERVICE_UUID = "00000030-0200-A58E-E411-AFE28044E62C"
WRITE_CHAR = "00000033-0200-A58E-E411-AFE28044E62C"
READ_CHAR = "00000034-0200-A58E-E411-AFE28044E62C"
_QUIET_EVENTS = {1, 3, 4, 26, 32}
def __init__(self, address: str, state_callback: StateCallback | None = None,
disconnect_callback: Callable[[], None] | None = None,
ble_device=None):
self.address = address
self._ble_device = ble_device
self._state_cb = state_callback
self._disconnect_cb = disconnect_callback
self.client: BleakClient | None = None
self.encoder = CobsEncoder()
self.decoder = CobsDecoder()
self._seq = 0
self._pending_commands: dict[int, CommandType] = {}
def set_ble_device(self, ble_device) -> None:
"""Update the BLE device reference (from HA scanner)."""
self._ble_device = ble_device
async def connect(self) -> None:
# Panel often drops the first BLE connection immediately.
# Retry the full connect→auth sequence with a fresh client each time.
# Do NOT call pair() — panel uses proprietary TEA auth, not BLE pairing,
# and the pair request may trigger the disconnect.
last_err: Exception | None = None
for attempt in range(5):
try:
if self._ble_device:
self.client = await establish_connection(
BleakClientWithServiceCache,
self._ble_device,
self.address,
disconnected_callback=self._on_disconnect,
max_attempts=2,
)
else:
self.client = BleakClient(self.address, disconnected_callback=self._on_disconnect)
await self.client.connect(timeout=30.0)
_LOGGER.info("BLE connected (attempt %d), starting auth...", attempt + 1)
# Brief settle time — just enough for BlueZ service discovery
await asyncio.sleep(0.5)
if not self.client.is_connected:
_LOGGER.info("Panel dropped connection on attempt %d — retrying", attempt + 1)
await asyncio.sleep(2.0)
continue
if not await _perform_auth(self.client):
if self.client.is_connected:
# Auth truly failed (not just disconnect) — still retry,
# the panel may need a warmup connection first
await self.client.disconnect()
_LOGGER.warning("Auth failed on attempt %d — retrying full sequence", attempt + 1)
await asyncio.sleep(2.0)
continue
await asyncio.sleep(0.5)
await self.client.start_notify(self.READ_CHAR, self._notification_handler)
_LOGGER.info("OneControl connected and authenticated (attempt %d)", attempt + 1)
return
except Exception as e:
last_err = e
_LOGGER.warning("Connect attempt %d failed: %s", attempt + 1, e)
if self.client and self.client.is_connected:
try:
await self.client.disconnect()
except Exception:
pass
await asyncio.sleep(2.0)
raise RuntimeError(f"OneControl: failed to connect after 4 attempts: {last_err}")
def _on_disconnect(self, _client: BleakClient) -> None:
_LOGGER.warning("OneControl: disconnected unexpectedly")
if self._disconnect_cb:
self._disconnect_cb()
async def disconnect(self) -> None:
if self.client and self.client.is_connected:
await self.client.disconnect()
@property
def is_connected(self) -> bool:
return self.client is not None and self.client.is_connected
def _notification_handler(self, sender, data: bytearray) -> None:
try:
decoded = self.decoder.decode(data)
if len(decoded) < 4:
return
event_type = decoded[0]
seq = struct.unpack(">H", decoded[1:3])[0]
resp_type = decoded[3]
payload = decoded[4:]
completed = resp_type >= 128
if event_type == 2:
cmd_type = self._pending_commands.get(seq)
if cmd_type == CommandType.GET_DEVICES and completed:
self._pending_commands.pop(seq, None)
elif cmd_type is not None and completed:
self._pending_commands.pop(seq, None)
else:
self._handle_status_event(event_type, decoded)
except Exception as e:
_LOGGER.debug("OneControl: error decoding notification: %s", e)
def _handle_status_event(self, event_type: int, decoded: bytes) -> None:
if event_type == 7 and len(decoded) >= 6:
v_raw = struct.unpack_from(">H", decoded, 1)[0]
features = decoded[5]
if features & 1 and v_raw != 0xFFFF:
voltage = v_raw / 256.0
self._emit(EventType.BATTERY, {"voltage": voltage})
elif event_type == 12:
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
pct = decoded[i + 1]
self._emit(EventType.TANK, {"device_id": dev_id, "pct": pct})
i += 2
elif event_type == 6:
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
state_byte = decoded[i + 1]
on = bool(state_byte & 0x01)
self._emit(EventType.SWITCH, {"device_id": dev_id, "on": on})
i += 7
elif event_type == 14:
# RelayHBridgeMomentaryType2 — covers/slides/awnings
# payload structure not fully decoded; pass raw for now
self._emit(EventType.COVER_RAW, {"data": decoded})
elif event_type not in self._QUIET_EVENTS:
_LOGGER.debug("OneControl: unhandled event %d: %s", event_type, decoded.hex())
def _emit(self, event_type: str, data: dict) -> None:
if self._state_cb:
self._state_cb(event_type, data)
def _next_seq(self) -> int:
self._seq = (self._seq + 1) & 0xFFFF
return self._seq
async def send_command(self, cmd_type: CommandType, table_id: int, payload: bytes) -> None:
if not self.is_connected:
raise RuntimeError("Not connected")
seq = self._next_seq()
self._pending_commands[seq] = cmd_type
packet = struct.pack(">HBB", seq, cmd_type, table_id) + payload
encoded = self.encoder.encode(packet)
# Panel's command char is a streaming (Write Without Response) endpoint,
# like the OEM app. Bleak 3.x defaults to write-with-response when the
# char advertises the "write" property, which the panel rejects with
# ATT 0x0E (Unlikely Error). Force write-without-response.
await self.client.write_gatt_char(self.WRITE_CHAR, encoded, response=False)
async def get_devices(self) -> None:
await self.send_command(CommandType.GET_DEVICES, 1, bytes([0, 255]))
async def set_switch(self, device_id: int, on: bool) -> None:
state = SwitchState.ON if on else SwitchState.OFF
await self.send_command(CommandType.ACTION_SWITCH, 1, bytes([state, device_id]))
async def control_movement(self, device_id: int, state: MovementState) -> None:
await self.send_command(CommandType.ACTION_MOVEMENT, 1, bytes([state, device_id]))
async def set_dimmer(self, device_id: int, level: int) -> None:
await self.send_command(CommandType.ACTION_DIMMABLE, 1, bytes([level, device_id]))
@@ -0,0 +1,53 @@
import re
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.data_entry_flow import FlowResult
from .client.onecontrol_client import OneControlClient
from .const import DOMAIN
_MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
class OneControlConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
address = user_input["address"].strip().upper()
if not _MAC_RE.match(address):
errors["address"] = "invalid_mac"
else:
await self.async_set_unique_id(address)
self._abort_if_unique_id_configured()
ble_device = async_ble_device_from_address(self.hass, address)
connected = False
for _ in range(2):
client = OneControlClient(address, ble_device=ble_device)
try:
await client.connect()
await client.disconnect()
connected = True
break
except Exception:
pass
if not connected:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"OneControl ({address})",
data={"address": address},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("address", description={"suggested_value": "30:C6:F7:E0:80:8E"}): str,
}),
errors=errors,
)
@@ -0,0 +1,25 @@
DOMAIN = "lippert_onecontrol"
# DevID → friendly name
SWITCH_DEVICES = {
4: "Water Pump",
5: "Gas Water Heater",
6: "Exterior Lights",
7: "Interior Lights",
}
TANK_DEVICES = {
8: "Grey Tank 2",
9: "Grey Tank 1",
10: "Black Tank",
11: "Fresh Water Tank",
}
COVER_DEVICES = {
2: "Cover 2", # slide or awning — confirm physically before renaming
3: "Cover 3",
}
KEEPALIVE_INTERVAL = 20 # seconds between GetDevices keepalives
MAX_KEEPALIVE_FAILURES = 3 # consecutive failures before forcing reconnect
RECONNECT_DELAYS = [5, 10, 20, 60] # seconds, last value repeats
@@ -0,0 +1,198 @@
import asyncio
import logging
from homeassistant.core import HomeAssistant
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .client.onecontrol_client import EventType, OneControlClient, MovementState
from .const import DOMAIN, RECONNECT_DELAYS, KEEPALIVE_INTERVAL, MAX_KEEPALIVE_FAILURES
_LOGGER = logging.getLogger(__name__)
class OneControlCoordinator(DataUpdateCoordinator):
"""Manages the BLE connection and pushes state to HA entities."""
def __init__(self, hass: HomeAssistant, address: str) -> None:
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=None)
self.address = address
self._switch_states: dict[int, bool] = {}
self._tank_levels: dict[int, int] = {}
self._battery_v: float | None = None
self._client = OneControlClient(
address,
state_callback=self._on_state,
disconnect_callback=self._on_disconnect,
)
self._keepalive_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
async def async_connect(self) -> None:
self._refresh_ble_device()
await self._client.connect()
await self._sync_state()
self._start_keepalive()
async def _sync_state(self) -> None:
"""Force an immediate state poll so HA reflects panel reality."""
try:
await self._client.get_devices()
await asyncio.sleep(1) # give notifications time to arrive
await self._client.get_devices() # second poll for reliability
except Exception as e:
_LOGGER.warning("Initial state sync failed: %s", e)
def _refresh_ble_device(self) -> None:
"""Get the latest BLE device reference from HA's scanner."""
ble_device = async_ble_device_from_address(self.hass, self.address)
if ble_device:
self._client.set_ble_device(ble_device)
else:
_LOGGER.warning("BLE device %s not found by HA scanner — using raw address", self.address)
async def async_disconnect(self) -> None:
self._cancel_tasks()
await self._client.disconnect()
# ------------------------------------------------------------------
# State accessors
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
return self._client.is_connected
def get_switch_state(self, device_id: int) -> bool | None:
return self._switch_states.get(device_id)
def get_tank_level(self, device_id: int) -> int | None:
return self._tank_levels.get(device_id)
@property
def battery_voltage(self) -> float | None:
return self._battery_v
# ------------------------------------------------------------------
# Commands
# ------------------------------------------------------------------
async def async_set_switch(self, device_id: int, on: bool) -> None:
await self._client.set_switch(device_id, on)
async def async_control_movement(self, device_id: int, state: MovementState) -> None:
await self._client.control_movement(device_id, state)
# ------------------------------------------------------------------
# State callback (called from BLE notification thread)
# ------------------------------------------------------------------
def _on_state(self, event_type: EventType, data: dict) -> None:
changed = False
if event_type == EventType.SWITCH:
dev_id = data["device_id"]
on = data["on"]
if self._switch_states.get(dev_id) != on:
self._switch_states[dev_id] = on
changed = True
elif event_type == EventType.TANK:
dev_id = data["device_id"]
pct = data["pct"]
if self._tank_levels.get(dev_id) != pct:
self._tank_levels[dev_id] = pct
changed = True
elif event_type == EventType.BATTERY:
v = data["voltage"]
if self._battery_v != v:
self._battery_v = v
changed = True
if changed:
self.hass.loop.call_soon_threadsafe(
lambda: self.hass.async_create_task(self._async_notify())
)
async def _async_notify(self) -> None:
# push-based: we call this manually instead of relying on update_interval
self.async_set_updated_data(None)
# ------------------------------------------------------------------
# Keepalive
# ------------------------------------------------------------------
def _start_keepalive(self) -> None:
self._cancel_tasks()
self._keepalive_task = self.hass.async_create_task(self._keepalive_loop())
async def _keepalive_loop(self) -> None:
consecutive_failures = 0
while True:
await asyncio.sleep(KEEPALIVE_INTERVAL)
if self._client.is_connected:
try:
await self._client.get_devices()
consecutive_failures = 0
except Exception as e:
consecutive_failures += 1
_LOGGER.warning(
"Keepalive failed (%d/%d): %s",
consecutive_failures, MAX_KEEPALIVE_FAILURES, e,
)
if consecutive_failures >= MAX_KEEPALIVE_FAILURES:
_LOGGER.error(
"Keepalive failed %d times — connection is dead, forcing reconnect",
consecutive_failures,
)
try:
await self._client.disconnect()
except Exception:
pass
self._on_disconnect()
return
# ------------------------------------------------------------------
# Reconnect
# ------------------------------------------------------------------
def _on_disconnect(self) -> None:
_LOGGER.warning("OneControl disconnected — scheduling reconnect")
self._cancel_tasks()
self._reconnect_task = self.hass.async_create_task(self._reconnect_loop())
async def _reconnect_loop(self) -> None:
delays = RECONNECT_DELAYS
for i, delay in enumerate(delays):
_LOGGER.info("Reconnecting in %ds (attempt %d)...", delay, i + 1)
await asyncio.sleep(delay)
try:
self._refresh_ble_device()
await self._client.connect()
_LOGGER.info("OneControl reconnected")
self._start_keepalive()
return
except Exception as e:
_LOGGER.warning("Reconnect attempt %d failed: %s", i + 1, e)
# Keep retrying at max interval indefinitely — RV may be off for hours
while True:
await asyncio.sleep(delays[-1])
try:
self._refresh_ble_device()
await self._client.connect()
_LOGGER.info("OneControl reconnected")
self._start_keepalive()
return
except Exception as e:
_LOGGER.warning("Reconnect failed: %s", e)
def _cancel_tasks(self) -> None:
for task in (self._keepalive_task, self._reconnect_task):
if task and not task.done():
task.cancel()
self._keepalive_task = None
self._reconnect_task = None
async def _async_update_data(self):
return None
@@ -0,0 +1,50 @@
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .client.onecontrol_client import MovementState
from .coordinator import OneControlCoordinator
from .const import DOMAIN, COVER_DEVICES
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: OneControlCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
OneControlCover(coordinator, device_id, name)
for device_id, name in COVER_DEVICES.items()
)
class OneControlCover(CoordinatorEntity, CoverEntity):
_attr_device_class = CoverDeviceClass.AWNING
_attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
)
def __init__(self, coordinator: OneControlCoordinator, device_id: int, name: str) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_name = name
self._attr_unique_id = f"{coordinator.address}_cover_{device_id}"
@property
def is_closed(self) -> bool | None:
# State tracking not yet decoded from cover_raw events; return None (unknown)
return None
@property
def available(self) -> bool:
return self.coordinator.is_connected
async def async_open_cover(self, **kwargs) -> None:
await self.coordinator.async_control_movement(self._device_id, MovementState.EXTEND)
async def async_close_cover(self, **kwargs) -> None:
await self.coordinator.async_control_movement(self._device_id, MovementState.RETRACT)
async def async_stop_cover(self, **kwargs) -> None:
await self.coordinator.async_control_movement(self._device_id, MovementState.STOP)
@@ -0,0 +1,9 @@
{
"domain": "lippert_onecontrol",
"name": "Lippert OneControl",
"version": "0.1.0",
"dependencies": ["bluetooth"],
"requirements": [],
"iot_class": "local_push",
"config_flow": true
}
@@ -0,0 +1,60 @@
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import OneControlCoordinator
from .const import DOMAIN, TANK_DEVICES
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: OneControlCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SensorEntity] = [
OneControlTankSensor(coordinator, device_id, name)
for device_id, name in TANK_DEVICES.items()
]
entities.append(OneControlBatterySensor(coordinator))
async_add_entities(entities)
class OneControlTankSensor(CoordinatorEntity, SensorEntity):
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: OneControlCoordinator, device_id: int, name: str) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_name = name
self._attr_unique_id = f"{coordinator.address}_tank_{device_id}"
@property
def native_value(self) -> int | None:
return self.coordinator.get_tank_level(self._device_id)
@property
def available(self) -> bool:
return self.coordinator.is_connected
class OneControlBatterySensor(CoordinatorEntity, SensorEntity):
_attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
_attr_device_class = SensorDeviceClass.VOLTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_name = "Battery Voltage"
def __init__(self, coordinator: OneControlCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.address}_battery"
@property
def native_value(self) -> float | None:
v = self.coordinator.battery_voltage
return round(v, 2) if v is not None else None
@property
def available(self) -> bool:
return self.coordinator.is_connected
@@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"title": "Lippert OneControl",
"description": "Enter the Bluetooth MAC address of the OneControl panel.",
"data": {
"address": "MAC Address"
}
}
},
"error": {
"cannot_connect": "Cannot connect to device. Verify it is powered on and in range.",
"invalid_mac": "Invalid MAC address format."
},
"abort": {
"already_configured": "This device is already configured."
}
}
}
@@ -0,0 +1,40 @@
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import OneControlCoordinator
from .const import DOMAIN, SWITCH_DEVICES
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: OneControlCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
OneControlSwitch(coordinator, device_id, name)
for device_id, name in SWITCH_DEVICES.items()
)
class OneControlSwitch(CoordinatorEntity, SwitchEntity):
def __init__(self, coordinator: OneControlCoordinator, device_id: int, name: str) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_name = name
self._attr_unique_id = f"{coordinator.address}_{device_id}"
@property
def is_on(self) -> bool | None:
return self.coordinator.get_switch_state(self._device_id)
@property
def available(self) -> bool:
return self.coordinator.is_connected
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_set_switch(self._device_id, True)
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_set_switch(self._device_id, False)
+110 -81
View File
@@ -2,121 +2,150 @@
# COBS encoding/decoding and CRC8 implementation for Lippert OneControl # COBS encoding/decoding and CRC8 implementation for Lippert OneControl
# Based on decompiled source from IDS.Portable.Common.COBS/CobsEncoder.cs and Crc8.cs # Based on decompiled source from IDS.Portable.Common.COBS/CobsEncoder.cs and Crc8.cs
from typing import List
class Crc8: class Crc8:
"""CRC8 with init value 0x55 (from Crc8.cs)""" """CRC8 using the exact lookup table from IDS.Portable.Common/Crc8.cs (init=0x55)."""
POLY = 0x07
def __init__(self, init: int = 0x55): _TABLE = [
self.value = init 0, 94, 188, 226, 97, 63, 221, 131, 194, 156,
126, 32, 163, 253, 31, 65, 157, 195, 33, 127,
252, 162, 64, 30, 95, 1, 227, 189, 62, 96,
130, 220, 35, 125, 159, 193, 66, 28, 254, 160,
225, 191, 93, 3, 128, 222, 60, 98, 190, 224,
2, 92, 223, 129, 99, 61, 124, 34, 192, 158,
29, 67, 161, 255, 70, 24, 250, 164, 39, 121,
155, 197, 132, 218, 56, 102, 229, 187, 89, 7,
219, 133, 103, 57, 186, 228, 6, 88, 25, 71,
165, 251, 120, 38, 196, 154, 101, 59, 217, 135,
4, 90, 184, 230, 167, 249, 27, 69, 198, 152,
122, 36, 248, 166, 68, 26, 153, 199, 37, 123,
58, 100, 134, 216, 91, 5, 231, 185, 140, 210,
48, 110, 237, 179, 81, 15, 78, 16, 242, 172,
47, 113, 147, 205, 17, 79, 173, 243, 112, 46,
204, 146, 211, 141, 111, 49, 178, 236, 14, 80,
175, 241, 19, 77, 206, 144, 114, 44, 109, 51,
209, 143, 12, 82, 176, 238, 50, 108, 142, 208,
83, 13, 239, 177, 240, 174, 76, 18, 145, 207,
45, 115, 202, 148, 118, 40, 171, 245, 23, 73,
8, 86, 180, 234, 105, 55, 213, 139, 87, 9,
235, 181, 54, 104, 138, 212, 149, 203, 41, 119,
244, 170, 72, 22, 233, 183, 85, 11, 136, 214,
52, 106, 43, 117, 151, 201, 74, 20, 246, 168,
116, 42, 200, 150, 21, 75, 169, 247, 182, 232,
10, 84, 215, 137, 107, 53,
]
def update(self, byte: int): RESET_VALUE = 0x55
self.value ^= byte
for _ in range(8):
if self.value & 0x80:
self.value = ((self.value << 1) ^ self.POLY) & 0xFF
else:
self.value = (self.value << 1) & 0xFF
def update_buffer(self, data: bytes):
for b in data:
self.update(b)
@staticmethod @staticmethod
def calculate(data: bytes, init: int = 0x55) -> int: def calculate(data: bytes) -> int:
crc = Crc8(init) crc = Crc8.RESET_VALUE
crc.update_buffer(data) for b in data:
return crc.value crc = Crc8._TABLE[(crc ^ b) & 0xFF]
return crc
class CobsEncoder: class CobsEncoder:
"""COBS Encoder matching CobsEncoder.cs (numDataBits=6, prependStartFrame=true, useCrc=true).
Code byte format: num_data_bytes + (num_consecutive_zeros * 64)
This differs from standard COBS — zeros are packed into the code byte, not output inline.
""" """
COBS Encoder matching DirectConnectionMyRvLinkBle.cs:44
prependStartFrame=true, useCrc=true, frameByte=0, numDataBits=6 LSB = 1 << 6 # 64 (FrameByteCountLsb)
""" MAX_DATA = LSB - 1 # 63 (MaxDataBytes)
MAX_ZEROS = 255 - MAX_DATA # 192 (MaxCompressedFrameBytes)
def __init__(self): def __init__(self):
self.frame_byte = 0x00 self.frame_byte = 0x00
self.max_data_bytes = (1 << 6) - 1 # 63 bytes (6-bit)
def encode(self, source: bytes) -> bytes: def encode(self, source: bytes) -> bytes:
"""Encode with CRC8 and COBS""" """Encode source bytes with CRC8 appended, using Lippert COBS variant."""
if not source:
return bytes([self.frame_byte])
# 1. Calculate CRC8
crc = Crc8.calculate(source) crc = Crc8.calculate(source)
data_with_crc = source + bytes([crc]) data = source + bytes([crc])
# 2. COBS encode
output = bytearray([self.frame_byte]) # Prepend start frame
output = bytearray([self.frame_byte]) # Start frame
i = 0 i = 0
while i < len(data_with_crc): while i < len(data):
code_index = len(output) code_index = len(output)
output.append(0xFF) # Placeholder for code byte output.append(0) # Placeholder for code byte
num6 = 0
count = 0 # Collect non-zero data bytes (up to MAX_DATA)
while i < len(data_with_crc) and count < self.max_data_bytes: while i < len(data) and num6 < self.MAX_DATA:
byte = data_with_crc[i] b = data[i]
if byte == self.frame_byte: if b == self.frame_byte:
break break
output.append(byte) output.append(b)
i += 1 num6 += 1
count += 1
# Update code byte
output[code_index] = count + 1
# Skip frame bytes
while i < len(data_with_crc) and data_with_crc[i] == self.frame_byte:
i += 1 i += 1
output.append(self.frame_byte) # Append end frame # Collect consecutive zero bytes, packing each as +LSB in the code byte
while i < len(data):
if data[i] != self.frame_byte:
break
num6 += self.LSB
i += 1
if num6 >= self.MAX_ZEROS:
break
output[code_index] = num6
output.append(self.frame_byte) # End frame
return bytes(output) return bytes(output)
class CobsDecoder: class CobsDecoder:
"""COBS Decoder for responses""" """COBS Decoder — stateful byte-at-a-time algorithm matching CobsDecoder.cs.
Code byte encoding (numDataBits=6):
code_byte = (num_zeros_to_insert << 6) | num_data_bytes_before_trigger
When the lower 6 bits of the running code_byte reach 0, the upper bits
tell how many zero bytes to insert. This differs from standard COBS.
"""
NUM_DATA_BITS = 6
LSB = 1 << NUM_DATA_BITS # 64
MAX_DATA_MASK = LSB - 1 # 63 (0x3F)
def __init__(self): def __init__(self):
self.frame_byte = 0x00 self.frame_byte = 0x00
def decode(self, encoded: bytes) -> bytes: def decode(self, encoded: bytes) -> bytes:
"""Decode COBS packet""" """Decode one complete COBS packet (start frame … end frame)."""
if not encoded or encoded[0] != self.frame_byte:
raise ValueError("Invalid COBS packet")
output = bytearray() output = bytearray()
i = 1 # Skip start frame code_byte = 0
while i < len(encoded): for b in encoded:
code = encoded[i] if b == self.frame_byte:
if code == 0: # Frame byte — either start (no data yet) or end (complete packet)
break if code_byte != 0 or len(output) == 0:
# Start frame or error: reset state
output.clear()
code_byte = 0
continue
# End frame: strip CRC and return
if len(output) < 1:
raise ValueError("Empty COBS packet")
received_crc = output[-1]
data = output[:-1]
calc_crc = Crc8.calculate(data)
if received_crc != calc_crc:
raise ValueError(f"CRC mismatch: received 0x{received_crc:02x} != calculated 0x{calc_crc:02x}")
return bytes(data)
i += 1 if code_byte <= 0:
count = code - 1 # Code byte: encodes data-count (low 6 bits) + zero-count (high bits)
code_byte = b
else:
# Data byte
code_byte -= 1
output.append(b)
# Copy data bytes # When lower NUM_DATA_BITS of code_byte hit zero, insert implied zeros
for _ in range(count): if (code_byte & self.MAX_DATA_MASK) == 0:
if i >= len(encoded): while code_byte > 0:
break output.append(self.frame_byte)
output.append(encoded[i]) code_byte -= self.LSB
i += 1
# Add frame byte if not at end raise ValueError("Incomplete COBS packet — no end frame received")
if code < 0xFF and i < len(encoded):
output.append(self.frame_byte)
# Verify CRC
if len(output) > 0:
data = output[:-1]
received_crc = output[-1]
calculated_crc = Crc8.calculate(bytes(data))
if received_crc != calculated_crc:
raise ValueError(f"CRC mismatch: {received_crc:02x} != {calculated_crc:02x}")
return bytes(output[:-1]) # Remove CRC
+222 -46
View File
@@ -9,6 +9,91 @@ 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.
C# uint arithmetic truncates to 32 bits at each step — every intermediate
shift/add must be masked independently, not just the final result."""
M = 0xFFFFFFFF
DELTA = 2654435769 # 0x9E3779B9
num = DELTA
for _ in range(32):
t = (((cypher << 4) & M) + 1131376761) & M
t ^= (cypher + num) & M
t ^= ((cypher >> 5) + 1919510376) & M
seed = (seed + t) & M
t = (((seed << 4) & M) + 1948272964) & M
t ^= (seed + num) & M
t ^= ((seed >> 5) + 1400073827) & M
cypher = (cypher + t) & M
num = (num + DELTA) & M
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] # big-endian: Endian.Big=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) # big-endian: Endian.Big=0
print(f"Auth: seed=0x{seed:08x} → key=0x{key:08x}")
try:
await client.write_gatt_char(KEY_CHAR, key_bytes, response=False)
await asyncio.sleep(0.5)
# Verify: 0012 should now return b"unlocked"
verify = await client.read_gatt_char(SEED_CHAR)
print(f"Auth: verify read = {verify!r} ({verify.hex()})")
if verify.lower() == b"unlocked":
print("Auth: confirmed unlocked!")
else:
print("Auth: key written but 'unlocked' not confirmed — may still work")
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,11 +129,18 @@ 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, disconnected_callback=self._on_disconnect)
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 asyncio.sleep(1.0) # Give panel a moment after auth before commands
await self.client.start_notify(self.READ_CHAR, self._notification_handler) await self.client.start_notify(self.READ_CHAR, self._notification_handler)
def _on_disconnect(self, _client: BleakClient):
print("[!] Device disconnected unexpectedly")
async def disconnect(self): async def disconnect(self):
"""Disconnect from device""" """Disconnect from device"""
if self.client and self.client.is_connected: if self.client and self.client.is_connected:
@@ -58,55 +150,151 @@ class OneControlClient:
"""Handle notifications from device""" """Handle notifications from device"""
try: try:
decoded = self.decoder.decode(data) decoded = self.decoder.decode(data)
# print(f"Received raw: {decoded.hex()}")
# Response Structure: [Event(1)] [Seq(2)] [RespType(1)] [Payload...] # Response Structure: [Event(1)] [Seq(2)] [RespType(1)] [Payload...]
if len(decoded) < 4: if len(decoded) < 4:
return return
event_type = decoded[0] event_type = decoded[0]
seq = struct.unpack("<H", decoded[1:3])[0] seq = struct.unpack(">H", decoded[1:3])[0]
resp_type = decoded[3] resp_type = decoded[3]
payload = decoded[4:] payload = decoded[4:]
# resp_type: 1=SuccessMultiple, 129=SuccessCompleted, 2=FailureMultiple, 130=FailureCompleted
completed = resp_type >= 128
# 2 = DeviceCommand response # 2 = DeviceCommand response
if event_type == 2: if event_type == 2:
cmd_type = self._pending_commands.get(seq) cmd_type = self._pending_commands.get(seq)
if cmd_type: if cmd_type:
if cmd_type == CommandType.GET_DEVICES: if cmd_type == CommandType.GET_DEVICES:
self._parse_get_devices_response(payload) if completed and len(payload) == 5:
# Completion packet: [CRC32 (4 bytes BE)][DeviceCount (1)]
crc = struct.unpack_from(">I", payload, 0)[0]
count = payload[4]
print(f"GetDevices complete: {count} devices, table CRC=0x{crc:08x}")
else:
self._parse_get_devices_response(payload)
elif cmd_type == CommandType.ACTION_SWITCH: elif cmd_type == CommandType.ACTION_SWITCH:
print(f"Switch Command Response (Seq {seq}): {payload.hex()}") print(f"Switch Response (Seq {seq}) resp=0x{resp_type:02x}: {payload.hex()}")
else: else:
print(f"Response (Seq {seq}, Cmd {cmd_type}): {payload.hex()}") print(f"Response (Seq {seq}, Cmd {cmd_type}) resp=0x{resp_type:02x}: {payload.hex()}")
# 0x81 (129) = SuccessCompleted, 0x82 (130) = FailureCompleted if completed:
if resp_type >= 128:
self._pending_commands.pop(seq, None) self._pending_commands.pop(seq, None)
else: else:
print(f"Received Response for unknown Seq {seq}: {payload.hex()}") print(f"Response for unknown Seq {seq} resp=0x{resp_type:02x}: {payload.hex()}")
else: else:
print(f"Received Event {event_type}: {decoded.hex()}") self._handle_status_event(event_type, decoded)
except Exception as e: except Exception as e:
print(f"Error decoding: {e}") print(f"Error decoding: {e}")
DEVICE_NAMES = {
4: "water pump",
5: "gas water heater",
6: "exterior lights",
7: "interior lights",
8: "grey tank 2",
9: "grey tank 1",
10: "black tank",
11: "fresh water tank",
}
_QUIET_EVENTS = {1, 3, 4, 26, 32} # noisy bookkeeping events suppressed from output
_EVENT_NAMES = {
5: "RelayLatchingType1", 8: "DimmableLightStatus",
9: "RgbLightStatus", 11: "HvacStatus",
13: "HBridgeType1", 14: "HBridgeType2",
}
def _handle_status_event(self, event_type: int, decoded: bytes):
"""Decode and print unsolicited status broadcasts."""
if event_type == 7 and len(decoded) >= 6:
# RvStatus: battery voltage + optional external temp
v_raw = struct.unpack_from(">H", decoded, 1)[0]
t_raw = struct.unpack_from(">H", decoded, 3)[0]
features = decoded[5]
if features & 1 and v_raw != 0xFFFF:
voltage = v_raw / 256.0
print(f"[Battery] {voltage:.2f}V", end="")
else:
print(f"[Battery] N/A", end="")
if features & 2 and t_raw != 0x7FFF:
temp_c = struct.unpack_from(">h", decoded, 3)[0] / 256.0
print(f" [Ext Temp] {temp_c:.1f}°C / {temp_c*9/5+32:.1f}°F", end="")
print()
elif event_type == 12:
# TankSensorStatus: [event][table][devId][pct] repeated
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
pct = decoded[i + 1]
name = self.DEVICE_NAMES.get(dev_id, f"tank DevID={dev_id}")
print(f"[Tank] {name}: {pct}%")
i += 2
elif event_type == 6:
# RelayBasicLatchingStatusType2: [event][table] then 7 bytes per device
# [devId][state_byte][5 more bytes]
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
state_byte = decoded[i + 1]
on = bool(state_byte & 0x01)
name = self.DEVICE_NAMES.get(dev_id, f"DevID={dev_id}")
print(f"[Switch] {name}: {'ON' if on else 'OFF'}")
i += 7
else:
if event_type not in self._QUIET_EVENTS:
name = self._EVENT_NAMES.get(event_type, f"Event{event_type}")
print(f"[{name}] {decoded.hex()}")
def _parse_get_devices_response(self, payload: bytes): def _parse_get_devices_response(self, payload: bytes):
"""Parse GetDevices response payload and print each device entry""" """Parse GetDevices intermediate response payload.
ExtendedData layout (payload = bytes 4+ of decoded packet):
[TableId (1)][StartingDeviceId (1)][DeviceCount (1)][device entries...]
Each device entry: [Protocol (1)][PayloadSize (1)][PayloadSize bytes]
Protocol 1 (IdsCan): PayloadSize=10
[DeviceType (1)][DeviceInstance (1)][ProductId (2 BE)][MAC (4)]
Protocol 2 (Host): varies
"""
if not payload: if not payload:
print("GetDevices: empty payload") print("GetDevices: empty payload")
return return
# Raw hex first — useful for cross-referencing against PROTOCOL_FINDINGS.md
print(f"GetDevices raw payload: {payload.hex()}") print(f"GetDevices raw payload: {payload.hex()}")
# Each device entry is 4 bytes: [DeviceID, DeviceType, TableID, Status] if len(payload) < 3:
# Structure inferred from protocol docs — verify against real hardware return
entry_size = 4 table_id = payload[0]
for i in range(0, len(payload) - (entry_size - 1), entry_size): start_id = payload[1]
device_id = payload[i] dev_count = payload[2]
device_type = payload[i + 1] print(f" TableId={table_id} StartId={start_id} DeviceCount={dev_count}")
table_id = payload[i + 2]
status = payload[i + 3] PROTOCOL_NAMES = {0: "None", 1: "IdsCan", 2: "Host"}
print(f" Device ID={device_id} Type=0x{device_type:02x} Table={table_id} Status=0x{status:02x}") i = 3
entry_num = start_id
while i + 1 < len(payload):
protocol = payload[i]
payload_size = payload[i + 1]
entry_data = payload[i + 2 : i + 2 + payload_size]
proto_name = PROTOCOL_NAMES.get(protocol, f"Proto{protocol}")
if protocol == 1 and payload_size >= 10: # IdsCan
dev_type = entry_data[0]
dev_instance = entry_data[1]
product_id = struct.unpack_from(">H", entry_data, 2)[0]
mac = entry_data[4:8].hex(":")
print(f" [{entry_num}] {proto_name} DevType=0x{dev_type:02x} Instance={dev_instance} ProductId=0x{product_id:04x} MAC={mac}")
else:
print(f" [{entry_num}] {proto_name} data={entry_data.hex()}")
i += 2 + payload_size
entry_num += 1
def _next_seq(self) -> int: def _next_seq(self) -> int:
"""Get next sequence number""" """Get next sequence number"""
@@ -121,11 +309,13 @@ class OneControlClient:
seq = self._next_seq() seq = self._next_seq()
self._pending_commands[seq] = cmd_type self._pending_commands[seq] = cmd_type
packet = struct.pack("<HBB", seq, cmd_type, table_id) + payload packet = struct.pack(">HBB", seq, cmd_type, table_id) + payload
encoded = self.encoder.encode(packet) encoded = self.encoder.encode(packet)
await self.client.write_gatt_char(self.WRITE_CHAR, encoded) # Write-without-response (Command) — panel rejects write-with-response
print(f"Sent (Seq {seq}): {packet.hex()}") # with ATT 0x0E. See client/onecontrol_client.py for details.
await self.client.write_gatt_char(self.WRITE_CHAR, encoded, response=False)
print(f"Sent (Seq {seq}): raw={packet.hex()} encoded={encoded.hex()}")
async def get_devices(self, start_id: int = 0, max_count: int = 255): async def get_devices(self, start_id: int = 0, max_count: int = 255):
"""Get list of devices (Command 1)""" """Get list of devices (Command 1)"""
@@ -168,7 +358,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):
@@ -181,23 +371,7 @@ async def scan_for_onecontrol():
async def main(): async def main():
found = await scan_for_onecontrol() address = "30:C6:F7:E0:80:8E"
if len(found) == 1:
address = found[0].address
print(f"Auto-connecting to {found[0].name} ({address})")
elif len(found) > 1:
print("Multiple OneControl devices found — set ADDRESS manually:")
for d in found:
print(f" {d.name} ({d.address})")
return
else:
# No device found via scan — set address manually if you already know it
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
client = OneControlClient(address) client = OneControlClient(address)
@@ -206,8 +380,10 @@ async def main():
await client.connect() await client.connect()
print("Connected! Requesting device list...") print("Connected! Requesting device list...")
await client.get_devices() # Panel has ~30s idle timeout — send a periodic ping to stay connected
await asyncio.sleep(5) # Wait for all response packets while True:
await asyncio.sleep(20)
await client.get_devices()
finally: finally:
await client.disconnect() await client.disconnect()