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:
co-authored by
Claude Sonnet 5
parent
7911339743
commit
918003d2e3
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user