ha: add carefree_bt12 integration for the Carefree Connects awning

Commands reverse-engineered from Bluetooth HCI snoop captures (adb bugreport)
of the official Carefree Connects (BT12) Android app, confirmed across three
independent captures including two isolated single-action live tests:
extend/retract (feature 0x05, values 0x01/0x02 -- the device toggles motor
state internally, there's no separate stop byte) and light on/off (feature
0x1a, values 0x19/0x01). Both live under GATT characteristic 02060002 on
service 02060001-50e1-405f-bab0-6bb582b4d96e.

Connects on demand per command (mirrors the app's own connect/act/disconnect
pattern) rather than holding a persistent connection like li3_battery, since
this device doesn't stream telemetry. Cover/light state is assumed/optimistic
-- the notify channel (02060003) isn't decoded yet, so a diagnostic sensor
just surfaces raw undecoded replies to build up data for that follow-on work.

Verified end-to-end against the real device: light on/off and awning
extend both worked through Home Assistant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-08-24 15:52:06 -04:00
co-authored by Claude Sonnet 5
parent 7911339743
commit 918003d2e3
10 changed files with 508 additions and 0 deletions
@@ -0,0 +1,25 @@
"""The Carefree Connects BT12 awning integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import Bt12Coordinator
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = Bt12Coordinator(hass, entry.data["address"])
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:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -0,0 +1,84 @@
"""Config flow for the Carefree Connects BT12 awning integration."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
class Bt12ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for a Carefree BT12 awning controller."""
VERSION = 1
def __init__(self) -> None:
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered: dict[str, str] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle a discovered BT12 advertisement (from any Bluetooth source)."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {"name": discovery_info.name}
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
assert self._discovery_info is not None
if user_input is not None:
return self.async_create_entry(
title=self._discovery_info.name,
data={
"address": self._discovery_info.address,
"name": self._discovery_info.name,
},
)
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders={"name": self._discovery_info.name},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manual entry, plus a dropdown of any BT12 already seen advertising."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input["address"]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
name = self._discovered.get(address, "BT12 Awning")
return self.async_create_entry(title=name, data={"address": address, "name": name})
current_addresses = self._async_current_ids()
for info in async_discovered_service_info(self.hass, connectable=True):
if info.address in current_addresses:
continue
if info.name == "BT12":
self._discovered[info.address] = info.name
if not self._discovered:
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("address"): str}),
errors=errors,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("address"): vol.In(self._discovered)}),
errors=errors,
)
@@ -0,0 +1,3 @@
"""Constants for the Carefree Connects BT12 awning integration."""
DOMAIN = "carefree_bt12"
@@ -0,0 +1,141 @@
"""On-demand BLE command dispatch to one Carefree BT12 awning controller.
Unlike the li3 battery, this device doesn't stream continuously -- the
official app connects, sends one command, and disconnects (or lingers
briefly on generic housekeeping unrelated to any command). We mirror the
"connect, act, disconnect" shape rather than holding a persistent connection.
Commands were reverse-engineered 2026-08-24 from Bluetooth HCI snoop captures
(adb bugreport) of the official "Carefree Connects (BT12)" Android app,
confirmed across three independent captures including two isolated single-
action captures. See vanlink project memory (carefree-bt12-*) for the full
methodology and raw evidence.
Both GATT characteristics live under service 02060001-50e1-405f-bab0-
6bb582b4d96e. All four known commands are Write Command (no response) to
02060002; 02060003 is the paired notify characteristic. There is a second,
unrelated write-only characteristic (71dc0002-9247-11e7-abc4-cec278b6b50a,
also the advertised service UUID) that the app never touched in any capture
-- not used here.
The notify channel is NOT decoded yet. We still subscribe and capture
whatever comes back (surfaced via a diagnostic sensor) purely to build up
data for that follow-on reverse-engineering effort -- don't assume the
values there mean anything yet.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from bleak import BleakClient
from bleak.exc import BleakError
from bleak_retry_connector import establish_connection
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
_LOGGER = logging.getLogger(__name__)
WRITE_CHAR_UUID = "02060002-50e1-405f-bab0-6bb582b4d96e"
NOTIFY_CHAR_UUID = "02060003-50e1-405f-bab0-6bb582b4d96e"
# Confirmed against three independent BLE HCI snoop captures. The device
# toggles motor state internally -- there is no separate "stop" byte,
# re-sending the same direction while it's moving is what stops it (matches
# the app's own UI, which has no Stop button either).
CMD_EXTEND = bytes.fromhex("80050101ffff")
CMD_RETRACT = bytes.fromhex("80050102ffff")
CMD_LIGHT_ON = bytes.fromhex("801a03030019ffff")
CMD_LIGHT_OFF = bytes.fromhex("801a03030001ffff")
NOTIFY_LISTEN_S = 3
MAX_NOTIFICATIONS_KEPT = 20
class Bt12Coordinator:
"""Owns on-demand BLE command dispatch and fans out state to entities."""
def __init__(self, hass: HomeAssistant, address: str) -> None:
self.hass = hass
self.address = address
self.moving_direction: str | None = None # "extend" | "retract" | None
self.light_on: bool | None = None
self.last_notifications: list[str] = [] # hex strings, most recent last
self._listeners: list[Callable[[], None]] = []
self._lock = asyncio.Lock()
@callback
def async_add_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]:
self._listeners.append(update_callback)
def remove_listener() -> None:
self._listeners.remove(update_callback)
return remove_listener
def _notify_listeners(self) -> None:
for update_callback in list(self._listeners):
update_callback()
async def async_send_command(self, payload: bytes) -> None:
"""Connect, write one command, listen briefly for notify replies, disconnect."""
async with self._lock:
ble_device = bluetooth.async_ble_device_from_address(
self.hass, self.address, connectable=True
)
if ble_device is None:
raise HomeAssistantError(
f"BT12 {self.address} not currently visible to any Bluetooth source"
)
def notify_handler(_sender, data: bytearray) -> None:
hex_val = data.hex()
_LOGGER.debug("BT12 %s notify: %s", self.address, hex_val)
self.last_notifications.append(hex_val)
del self.last_notifications[:-MAX_NOTIFICATIONS_KEPT]
self._notify_listeners()
try:
client = await establish_connection(BleakClient, ble_device, ble_device.address)
except (BleakError, EOFError, TimeoutError) as err:
raise HomeAssistantError(f"BT12 {self.address} connect failed: {err}") from err
try:
await client.start_notify(NOTIFY_CHAR_UUID, notify_handler)
await client.write_gatt_char(WRITE_CHAR_UUID, payload, response=False)
await asyncio.sleep(NOTIFY_LISTEN_S)
finally:
if client.is_connected:
await client.disconnect()
async def async_extend(self) -> None:
await self.async_send_command(CMD_EXTEND)
self.moving_direction = None if self.moving_direction == "extend" else "extend"
self._notify_listeners()
async def async_retract(self) -> None:
await self.async_send_command(CMD_RETRACT)
self.moving_direction = None if self.moving_direction == "retract" else "retract"
self._notify_listeners()
async def async_stop(self) -> None:
"""No dedicated stop byte -- resend whichever direction is currently moving."""
if self.moving_direction == "extend":
await self.async_send_command(CMD_EXTEND)
elif self.moving_direction == "retract":
await self.async_send_command(CMD_RETRACT)
self.moving_direction = None
self._notify_listeners()
async def async_light_on(self) -> None:
await self.async_send_command(CMD_LIGHT_ON)
self.light_on = True
self._notify_listeners()
async def async_light_off(self) -> None:
await self.async_send_command(CMD_LIGHT_OFF)
self.light_on = False
self._notify_listeners()
@@ -0,0 +1,80 @@
"""Cover platform for the Carefree BT12 awning."""
from __future__ import annotations
from typing import Any
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import Bt12Coordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: Bt12Coordinator = hass.data[DOMAIN][entry.entry_id]
device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data["address"])},
name=entry.data.get("name", "BT12 Awning"),
manufacturer="Carefree of Colorado",
model="BT12",
)
async_add_entities([Bt12Cover(coordinator, device_info)])
class Bt12Cover(CoverEntity):
"""The awning. Open = extended, close = retracted.
Blind control only -- no position/status feedback decoded yet, so state
here is entirely assumed/optimistic (see coordinator.py's
last_notifications for the raw, still-undecoded notify traffic). There's
also no dedicated stop command on this device: re-sending whichever
direction is currently moving is what stops it, so stop_cover just
replays the last-commanded direction.
"""
_attr_has_entity_name = True
_attr_name = None
_attr_assumed_state = True
_attr_should_poll = False
_attr_device_class = CoverDeviceClass.AWNING
_attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
)
def __init__(self, coordinator: Bt12Coordinator, device_info: DeviceInfo) -> None:
self._coordinator = coordinator
self._attr_unique_id = f"{coordinator.address}_awning"
self._attr_device_info = device_info
@property
def is_closed(self) -> bool | None:
return None # unknown -- no position feedback decoded yet
@property
def is_opening(self) -> bool:
return self._coordinator.moving_direction == "extend"
@property
def is_closing(self) -> bool:
return self._coordinator.moving_direction == "retract"
async def async_open_cover(self, **kwargs: Any) -> None:
await self._coordinator.async_extend()
async def async_close_cover(self, **kwargs: Any) -> None:
await self._coordinator.async_retract()
async def async_stop_cover(self, **kwargs: Any) -> None:
await self._coordinator.async_stop()
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
@callback
def _handle_update(self) -> None:
self.async_write_ha_state()
@@ -0,0 +1,64 @@
"""Light platform for the Carefree BT12 awning's built-in LED strip."""
from __future__ import annotations
from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import Bt12Coordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: Bt12Coordinator = hass.data[DOMAIN][entry.entry_id]
device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data["address"])},
name=entry.data.get("name", "BT12 Awning"),
manufacturer="Carefree of Colorado",
model="BT12",
)
async_add_entities([Bt12Light(coordinator, device_info)])
class Bt12Light(LightEntity):
"""The awning's LED strip.
"On" always sends a fixed level byte (0x19) -- no brightness-setting
command has been decoded yet (the app's slider wasn't isolated in
reverse-engineering), so this is on/off only for now.
"""
_attr_has_entity_name = True
_attr_name = "Light"
_attr_assumed_state = True
_attr_should_poll = False
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
def __init__(self, coordinator: Bt12Coordinator, device_info: DeviceInfo) -> None:
self._coordinator = coordinator
self._attr_unique_id = f"{coordinator.address}_light"
self._attr_device_info = device_info
@property
def is_on(self) -> bool | None:
return self._coordinator.light_on
async def async_turn_on(self, **kwargs: Any) -> None:
await self._coordinator.async_light_on()
async def async_turn_off(self, **kwargs: Any) -> None:
await self._coordinator.async_light_off()
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
@callback
def _handle_update(self) -> None:
self.async_write_ha_state()
@@ -0,0 +1,17 @@
{
"domain": "carefree_bt12",
"name": "Carefree Connects BT12 Awning",
"codeowners": ["@aew"],
"config_flow": true,
"dependencies": ["bluetooth"],
"documentation": "https://github.com/wrede/vanlink",
"iot_class": "local_push",
"requirements": ["bleak-retry-connector>=3.0.0"],
"version": "0.1.0",
"bluetooth": [
{
"local_name": "BT12",
"connectable": true
}
]
}
@@ -0,0 +1,60 @@
"""Diagnostic sensor exposing raw BT12 notify replies, undecoded.
The notify channel (02060003) isn't reverse-engineered yet. This entity just
surfaces whatever comes back after each command, in order to build up real
data for that follow-on work -- don't assume the values mean anything yet.
"""
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import Bt12Coordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: Bt12Coordinator = hass.data[DOMAIN][entry.entry_id]
device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data["address"])},
name=entry.data.get("name", "BT12 Awning"),
manufacturer="Carefree of Colorado",
model="BT12",
)
async_add_entities([Bt12LastNotification(coordinator, device_info)])
class Bt12LastNotification(SensorEntity):
"""Raw hex of the most recent GATT notification."""
_attr_has_entity_name = True
_attr_name = "Last Notification"
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: Bt12Coordinator, device_info: DeviceInfo) -> None:
self._coordinator = coordinator
self._attr_unique_id = f"{coordinator.address}_last_notification"
self._attr_device_info = device_info
@property
def native_value(self) -> str | None:
if not self._coordinator.last_notifications:
return None
return self._coordinator.last_notifications[-1]
@property
def extra_state_attributes(self) -> dict:
return {"recent": self._coordinator.last_notifications}
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
@callback
def _handle_update(self) -> None:
self.async_write_ha_state()
@@ -0,0 +1,17 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Add the awning controller `{name}`?"
},
"user": {
"data": {
"address": "Device"
}
}
},
"abort": {
"already_configured": "This awning controller is already configured"
}
}
}
@@ -0,0 +1,17 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Add the awning controller `{name}`?"
},
"user": {
"data": {
"address": "Device"
}
}
},
"abort": {
"already_configured": "This awning controller is already configured"
}
}
}