From 918003d2e3b66e33f144608286be46e8f40e46bd Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 24 Aug 2026 15:52:06 -0400 Subject: [PATCH] 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 --- .../carefree_bt12/__init__.py | 25 ++++ .../carefree_bt12/config_flow.py | 84 +++++++++++ ha/custom_components/carefree_bt12/const.py | 3 + .../carefree_bt12/coordinator.py | 141 ++++++++++++++++++ ha/custom_components/carefree_bt12/cover.py | 80 ++++++++++ ha/custom_components/carefree_bt12/light.py | 64 ++++++++ .../carefree_bt12/manifest.json | 17 +++ ha/custom_components/carefree_bt12/sensor.py | 60 ++++++++ .../carefree_bt12/strings.json | 17 +++ .../carefree_bt12/translations/en.json | 17 +++ 10 files changed, 508 insertions(+) create mode 100644 ha/custom_components/carefree_bt12/__init__.py create mode 100644 ha/custom_components/carefree_bt12/config_flow.py create mode 100644 ha/custom_components/carefree_bt12/const.py create mode 100644 ha/custom_components/carefree_bt12/coordinator.py create mode 100644 ha/custom_components/carefree_bt12/cover.py create mode 100644 ha/custom_components/carefree_bt12/light.py create mode 100644 ha/custom_components/carefree_bt12/manifest.json create mode 100644 ha/custom_components/carefree_bt12/sensor.py create mode 100644 ha/custom_components/carefree_bt12/strings.json create mode 100644 ha/custom_components/carefree_bt12/translations/en.json diff --git a/ha/custom_components/carefree_bt12/__init__.py b/ha/custom_components/carefree_bt12/__init__.py new file mode 100644 index 0000000..805244c --- /dev/null +++ b/ha/custom_components/carefree_bt12/__init__.py @@ -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 diff --git a/ha/custom_components/carefree_bt12/config_flow.py b/ha/custom_components/carefree_bt12/config_flow.py new file mode 100644 index 0000000..0426395 --- /dev/null +++ b/ha/custom_components/carefree_bt12/config_flow.py @@ -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, + ) diff --git a/ha/custom_components/carefree_bt12/const.py b/ha/custom_components/carefree_bt12/const.py new file mode 100644 index 0000000..d635a33 --- /dev/null +++ b/ha/custom_components/carefree_bt12/const.py @@ -0,0 +1,3 @@ +"""Constants for the Carefree Connects BT12 awning integration.""" + +DOMAIN = "carefree_bt12" diff --git a/ha/custom_components/carefree_bt12/coordinator.py b/ha/custom_components/carefree_bt12/coordinator.py new file mode 100644 index 0000000..407efb7 --- /dev/null +++ b/ha/custom_components/carefree_bt12/coordinator.py @@ -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() diff --git a/ha/custom_components/carefree_bt12/cover.py b/ha/custom_components/carefree_bt12/cover.py new file mode 100644 index 0000000..88a69f9 --- /dev/null +++ b/ha/custom_components/carefree_bt12/cover.py @@ -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() diff --git a/ha/custom_components/carefree_bt12/light.py b/ha/custom_components/carefree_bt12/light.py new file mode 100644 index 0000000..f85e555 --- /dev/null +++ b/ha/custom_components/carefree_bt12/light.py @@ -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() diff --git a/ha/custom_components/carefree_bt12/manifest.json b/ha/custom_components/carefree_bt12/manifest.json new file mode 100644 index 0000000..0f59920 --- /dev/null +++ b/ha/custom_components/carefree_bt12/manifest.json @@ -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 + } + ] +} diff --git a/ha/custom_components/carefree_bt12/sensor.py b/ha/custom_components/carefree_bt12/sensor.py new file mode 100644 index 0000000..dbd0251 --- /dev/null +++ b/ha/custom_components/carefree_bt12/sensor.py @@ -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() diff --git a/ha/custom_components/carefree_bt12/strings.json b/ha/custom_components/carefree_bt12/strings.json new file mode 100644 index 0000000..f6c27ac --- /dev/null +++ b/ha/custom_components/carefree_bt12/strings.json @@ -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" + } + } +} diff --git a/ha/custom_components/carefree_bt12/translations/en.json b/ha/custom_components/carefree_bt12/translations/en.json new file mode 100644 index 0000000..f6c27ac --- /dev/null +++ b/ha/custom_components/carefree_bt12/translations/en.json @@ -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" + } + } +}