diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..203b4db --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @verdel \ No newline at end of file diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..afaf0bb --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + - package-ecosystem: pip + directory: "/" + schedule: + interval: daily diff --git a/.github/workflows/hacs.yaml b/.github/workflows/hacs.yaml new file mode 100644 index 0000000..a03a60b --- /dev/null +++ b/.github/workflows/hacs.yaml @@ -0,0 +1,33 @@ +name: HACS validation + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + hacs: + runs-on: "ubuntu-latest" + name: HACS + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: HACS validation + uses: "hacs/action@22.5.0" + with: + category: "integration" + ignore: brands + + hassfest: + runs-on: "ubuntu-latest" + name: Hassfest + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ca6fcd1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: "v0.2.2" + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format diff --git a/README.md b/README.md new file mode 100644 index 0000000..930e64c --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Home Assistant custom component for Petoneer SmartDot + +This is a custom component for Home Assistant that allows the control of the Petoneer SmartDot via bluetooth. + +![Petoneer SmartDot](assets/petoneer-smartdot.png) + +# Installation + +This custom component can be installed in two different ways: `manually` or `using HACS` + +## 1. Installation using HACS (recommended) + +This repo is now in [HACS](https://hacs.xyz/). + +1. Install HACS follow the instructions [here](https://hacs.xyz/docs/setup/prerequisites) +2. Search for `Petoneer SmartDot` +3. Install and enjoy automatic updates + +## 2. Manual Installation + +1. Download the zip file from the + [latest release](https://github.com/verdel/hass-petoneer-smartdot/releases/latest). +2. Unpack the release and copy the `custom_components/petoneer_smartdot` directory + into the `custom_components` directory of your Home Assistant + installation. +3. Ensure bluez is installed and accessible from HA (refer to next section) +4. Add the `petoneer_smartdot` as described in next section. + +## Ensure Host bluetooth is accessible from Home-Assistant + +Since version 1.0.0, this component uses the [`bleak`](https://github.com/hbldh/bleak) python library to access bluetooth (as bluepy is not supported from HA 2022.07+). In order to scan and interact with bluetooth devices, bluez utility needs to be installed and the correct permissions must be given to HA: + +- for **Home Assistant Operating System**: + It should be all setup, at least for HA 2022.7+ + +- For **Home Assistant Container** in docker: + + Ensure your host has the `bluetoothctl` binary on the system (coming from `bluez` or `bluez-util` package, depending on the distro). + The docker-compose container (or equivalent docker command) should link _/var/run/dbus_ with host folder through a volume and _NET_ADMIN_ permission is needed. docker compose extract: + + ```yaml + volumes: + - /run/dbus:/run/dbus:ro + cap_add: + - NET_ADMIN + - NET_RAW + network_mode: host + ``` + +- For **Home Assistant Core** installed in a Virtualenv: + + Ensure your host has the `bluetoothctl` binary on the system (coming from `bluez` or `bluez-util` package, depending on the distro). + Make sure the user running HA belongs to the `bluetooth` group. + +# Homeassistant component configuration + +## Adding the device to HA + +You must have the `bluetooth` integration enabled and configured (HA 2022.8+) or a connected ESPhome device running the bluetooth proxy (HA 2022.10+). The Petoneer SmartDot should be automatically discovered and you will receive a notification prompting you to add it. + +The devices can also be added through the `integration menu` UI: + +- In Configuration/Integrations click on the + button, select `Petoneer SmartDot` and you can either scan for the devices or configure the name and mac address manually on the form. + The SmartDot is automatically added and a device is created. + +Please ensure the following steps prior to adding a new SmartDot: + +- The SmartDot must NOT be connected with the official app (or any other device), else HA will not be able to discover it, nor connect to it. +- Some HA integrations still use some bluetooth libraries that take full control of the physical bluetooth adapter, in that case, other ble integration will not have access to it. So to test this component, best to disable all other ble integrations if you are unsure what ble lib they are using. + +# Debugging + +Please ensure the following: + +1. the petoneer_smartdot integration has been removed from HA. +2. HA has access to the bluetooth adapter (follow the section above in not on HAOS). +3. No other bluetooth integration are using something else than bleak library for bluetooth. If unsure, disable them. +4. The logging has been changed in HA to allow debugging of this component and bleak: + In order to get more information on what is going on, the debugging flag can be enabled by placing in the `configuration.yaml` of Home assistant: + + ```yaml + logger: + default: warning + logs: + custom_components.petoneer_smartdot: debug + bleak_retry_connector: debug + bleak: debug + # homeassistant.components.bluetooth: debug # this can help if needed + # homeassistant.components.esphome.bluetooth: debug # this can help if needed + ``` + + NOTE: this will generate A LOT of debugging messages in the logs, so it is not recommended to use for a long time + +5. Restart HA +6. Reinstall the petoneer_smartdot integration and find the SmartDot through a scan. +7. check the logs and report. Thanks + +# Other info + +Originally based on the work by Marco Colombo [hass-addon-petoneer-smartdot](https://github.com/marcomow/hass-addon-petoneer-smartdot). diff --git a/assets/petoneer-smartdot.png b/assets/petoneer-smartdot.png new file mode 100644 index 0000000..5c0591f Binary files /dev/null and b/assets/petoneer-smartdot.png differ diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/petoneer_smartdot/__init__.py b/custom_components/petoneer_smartdot/__init__.py new file mode 100644 index 0000000..fb30a0c --- /dev/null +++ b/custom_components/petoneer_smartdot/__init__.py @@ -0,0 +1,48 @@ +"""The Petoneer SmartDot integration.""" + +import logging + +from homeassistant.components.bluetooth import async_ble_device_from_address, async_scanner_count +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up petoneer_smartdot from a config entry.""" + _LOGGER.debug("integration async setup entry: {entry.as_dict()}") + hass.data.setdefault(DOMAIN, {}) + + address = entry.data.get(CONF_MAC) + + ble_device = async_ble_device_from_address(hass, address.upper(), connectable=True) + _LOGGER.debug("BLE device through HA bt: %s", ble_device) + if ble_device is None: + count_scanners = async_scanner_count(hass, connectable=True) + _LOGGER.debug("Count of BLE scanners in HA bt: %s", count_scanners) + if count_scanners < 1: + raise ConfigEntryNotReady( + "No bluetooth scanner detected. \ + Enable the bluetooth integration or ensure an esphome device \ + is running as a bluetooth proxy" + ) + raise ConfigEntryNotReady(f"Could not find Petoneer SmartDot with address {address}") + + hass.data[DOMAIN][entry.entry_id] = {"id": entry.entry_id, "device": ble_device, "mac": address} + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("async unload entry") + 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/custom_components/petoneer_smartdot/button.py b/custom_components/petoneer_smartdot/button.py new file mode 100644 index 0000000..031396c --- /dev/null +++ b/custom_components/petoneer_smartdot/button.py @@ -0,0 +1,153 @@ +import asyncio +import logging + +from bleak import BleakClient, BleakError +from bleak_retry_connector import establish_connection +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import async_entries_for_config_entry, async_get + +from .const import DOMAIN, MODES, UUID_CONTROL_MODE + +_LOGGER = logging.getLogger(__name__) + + +async def get_select_entity_value(hass, entry_id) -> str | None: + """Get game mode from select entity value.""" + + entity_registry = async_get(hass) + entries = async_entries_for_config_entry(entity_registry, entry_id) + select_entities = [entry for entry in entries if entry.domain == "select"] + + if select_entities: + select_entity_id = select_entities[0].entity_id + select_entity_state = hass.states.get(select_entity_id) + return select_entity_state.state + + return None + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + """Setup button entities.""" + + data = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SmartDotStartButtonEntity(hass, entry.entry_id, data), SmartDotStopButtonEntity(data)]) + + +class SmartDotStartButtonEntity(ButtonEntity): + """Petoneer SmartDot button entity for game start.""" + + _attr_should_poll = False + + def __init__(self, hass, entry_id, data) -> None: + """Initialize the button entity.""" + + self._attr_unique_id = f"{data.get('id')}_start_game" + self._attr_name = "Start" + self._attr_icon = "mdi:play" + self._mac = data.get("mac") + self._ble_device = data.get("device") + self._hass = hass + self._entry_id = entry_id + + _LOGGER.debug("Initializing BLE Device %s (%s)", self._ble_device.name, self._mac) + _LOGGER.debug("BLE_device details: %s", self._ble_device.details) + + async def async_press(self) -> None: + """Restore last state when added.""" + game_mode = await get_select_entity_value(self._hass, self._entry_id) + if game_mode is None: + _LOGGER.debug("Select game mode first") + return + _LOGGER.debug("Current game mode: %s", game_mode) + _LOGGER.debug("Connecting") + try: + client = await establish_connection( + client_class=BleakClient, device=self._ble_device, name=self._ble_device.address + ) + except asyncio.TimeoutError: + _LOGGER.error("Connection Timeout error") + except BleakError as err: + _LOGGER.error("Connection: BleakError: %s", err) + + try: + _LOGGER.debug("Sending command") + await client.write_gatt_char( + UUID_CONTROL_MODE, + bytearray.fromhex(MODES[game_mode]), + response=False, + ) + await asyncio.sleep(0.1) + except asyncio.TimeoutError: + _LOGGER.error("Connection Timeout error") + except BleakError as err: + _LOGGER.error("Connection: BleakError: %s", err) + await client.disconnect() + _LOGGER.debug("Disconnected") + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + identifiers={(DOMAIN, self._mac)}, + name=f"Petoneer SmartDot ({self._mac})", + manufacturer="Petoneer", + model="SmartDot", + ) + + +class SmartDotStopButtonEntity(ButtonEntity): + """Petoneer SmartDot button entity for game stop.""" + + _attr_should_poll = False + + def __init__(self, data) -> None: + """Initialize the button entity.""" + + self._attr_unique_id = f"{data.get('id')}_stop_game" + self._attr_name = "Stop" + self._attr_icon = "mdi:stop" + self._mac = data.get("mac") + self._ble_device = data.get("device") + _LOGGER.debug("Initializing BLE Device %s (%s)", self._ble_device.name, self._mac) + _LOGGER.debug("BLE_device details: {self._ble_device.details}") + + async def async_press(self) -> None: + """Restore last state when added.""" + _LOGGER.debug("Connecting") + try: + client = await establish_connection( + client_class=BleakClient, device=self._ble_device, name=self._ble_device.address + ) + except asyncio.TimeoutError: + _LOGGER.error("Connection Timeout error") + except BleakError as err: + _LOGGER.error("Connection: BleakError: %s", err) + + try: + _LOGGER.debug("Sending command") + await client.write_gatt_char( + UUID_CONTROL_MODE, + bytearray.fromhex(MODES["stop"]), + response=False, + ) + await asyncio.sleep(0.1) + except asyncio.TimeoutError: + _LOGGER.error("Connection Timeout error") + except BleakError as err: + _LOGGER.error("Connection: BleakError: %s", err) + await client.disconnect() + _LOGGER.debug("Disconnected") + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + identifiers={(DOMAIN, self._mac)}, + name=f"Petoneer SmartDot ({self._mac})", + manufacturer="Petoneer", + model="SmartDot", + ) diff --git a/custom_components/petoneer_smartdot/config_flow.py b/custom_components/petoneer_smartdot/config_flow.py new file mode 100644 index 0000000..46d982f --- /dev/null +++ b/custom_components/petoneer_smartdot/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for petoneer smartdot""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from bleak import BleakError, BleakScanner +from habluetooth.scanner import create_bleak_scanner +from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothScanningMode, BluetoothServiceInfoBleak, async_get_scanner +from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr + +from .const import CONF_ENTRY_MANUAL, CONF_ENTRY_METHOD, CONF_ENTRY_SCAN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def discover_petoneer_smartdot( + scanner: type[BleakScanner] | None = None, +) -> list[dict[str, Any]]: + """Scanning feature + Scan the BLE neighborhood for an Petoneer SmartDot + This method requires the script to be launched as root + Returns the list of nearby devices + """ + device_list = [] + scanner = scanner if scanner is not None else BleakScanner + + devices = await scanner.discover() + for d in devices: + if d.name == "PetCat": + device_list.append({"ble_device": d, "model": "PetCat"}) + _LOGGER.debug("Found 'PetCat' with mac: %s, details: %s", d.address, d.details) + return device_list + + +class SmartDotFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore + """Handle a config flow for petoneer smartdot.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @property + def data_schema(self) -> vol.Schema: + """Return the data schema for integration.""" + return vol.Schema({vol.Required(CONF_NAME): str, vol.Required(CONF_MAC): str}) + + async def async_step_bluetooth(self, discovery_info: BluetoothServiceInfoBleak) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered bluetooth device: %s", discovery_info) + await self.async_set_unique_id(dr.format_mac(discovery_info.address)) + self._abort_if_unique_id_configured() + + self.devices = [f"{discovery_info.address} ({discovery_info.name})"] + return await self.async_step_device() + + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: + """Handle a flow initialized by the user.""" + + if user_input is None: + schema = {vol.Required(CONF_ENTRY_METHOD): vol.In([CONF_ENTRY_SCAN, CONF_ENTRY_MANUAL])} + return self.async_show_form(step_id="user", data_schema=vol.Schema(schema)) + method = user_input[CONF_ENTRY_METHOD] + _LOGGER.debug("Method selected: %s", method) + if method == CONF_ENTRY_SCAN: + return await self.async_step_scan() + else: + self.devices = [] + return await self.async_step_device() + + async def async_step_scan(self, user_input: dict[str, Any] | None = None) -> FlowResult: + """Handle the discovery by scanning.""" + errors = {} + scanner = async_get_scanner(self.hass) + _LOGGER.debug("Preparing for a scan") + try: + if len(scanner.discovered_devices) >= 1: + _LOGGER.debug("Using HA scanner %s", scanner) + except AttributeError: + scanner = create_bleak_scanner(BluetoothScanningMode.ACTIVE, None) + _LOGGER.debug("Using bleak scanner through HA") + try: + _LOGGER.debug("Starting a scan for Petoneer SmartDot devices") + ble_devices = await discover_petoneer_smartdot(scanner) + except BleakError as err: + _LOGGER.error("Bluetooth connection error while trying to scan: %s", err) + errors["base"] = "BleakError" + return self.async_show_form(step_id="scan", errors=errors) + + if not ble_devices: + return self.async_abort(reason="no_devices_found") + self.devices = [f"{dev['ble_device'].address} ({dev['model']})" for dev in ble_devices] + return await self.async_step_device() + + async def async_step_device(self, user_input: dict[str, Any] | None = None) -> FlowResult: + """Handle setting up a device.""" + if not user_input: + schema_mac = str + if self.devices: + schema_mac = vol.In(self.devices) + schema = vol.Schema({vol.Required(CONF_MAC): schema_mac}) + return self.async_show_form(step_id="device", data_schema=schema) + + user_input[CONF_MAC] = user_input[CONF_MAC][:17] + unique_id = dr.format_mac(user_input[CONF_MAC]) + _LOGGER.debug("Petoneer SmartDot ID: %s", unique_id) + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=unique_id, data=user_input) diff --git a/custom_components/petoneer_smartdot/const.py b/custom_components/petoneer_smartdot/const.py new file mode 100644 index 0000000..5d17851 --- /dev/null +++ b/custom_components/petoneer_smartdot/const.py @@ -0,0 +1,14 @@ +"""Constants for the Petoneer SmartDot integration.""" + +DOMAIN = "petoneer_smartdot" +UUID_CONTROL_MODE = "0000fff3-0000-1000-8000-00805f9b34fb" +MODES = { + "stop": "0f0407000008", + "small": "0f0405000107", + "medium": "0f0405000208", + "large": "0f0405000309", +} +CONF_ENTRY_METHOD = "entry_method" +CONF_ENTRY_SCAN = "Scan" +CONF_ENTRY_MANUAL = "Enter MAC manually" +PLATFORMS: list[str] = ["select", "button"] diff --git a/custom_components/petoneer_smartdot/manifest.json b/custom_components/petoneer_smartdot/manifest.json new file mode 100644 index 0000000..291c49b --- /dev/null +++ b/custom_components/petoneer_smartdot/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "petoneer_smartdot", + "name": "Petoneer SmartDot", + "bluetooth": [ + { + "connectable": true, + "local_name": "PetCat" + } + ], + "codeowners": ["@verdel"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://github.com/verdel/hass-petoneer-smartdot", + "iot_class": "assumed_state", + "issue_tracker": "https://github.com/verdel/hass-petoneer-smartdot/issues", + "requirements": ["bleak-retry-connector"], + "version": "0.1.0" +} diff --git a/custom_components/petoneer_smartdot/select.py b/custom_components/petoneer_smartdot/select.py new file mode 100644 index 0000000..a4d36ac --- /dev/null +++ b/custom_components/petoneer_smartdot/select.py @@ -0,0 +1,56 @@ +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DOMAIN, MODES + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + """Setup select entity.""" + + data = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SmartDotSelectEntity(data)]) + + +class SmartDotSelectEntity(SelectEntity, RestoreEntity): + """Petoneer SmartDot select entity for game mode change.""" + + _attr_should_poll = False + + def __init__(self, data) -> None: + """Initialize the game mode select entity.""" + + self._attr_unique_id = f"{data.get('id')}_game_preset" + self._attr_name = "Game preset" + self._attr_translation_key = "preset" + self._attr_options = [key for key in MODES if key != "stop"] + self._attr_icon = "mdi:paw" + self._mac = data.get("mac") + self._attr_current_option = "small" + + async def async_added_to_hass(self) -> None: + """Restore last state when added.""" + last_state = await self.async_get_last_state() + if last_state: + self._attr_current_option = last_state.state + + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + if option not in self.options: + raise ValueError(f"Invalid option for {self.entity_id}: {option}") + + self._attr_current_option = option + self.async_write_ha_state() + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + identifiers={(DOMAIN, self._mac)}, + name=f"Petoneer SmartDot ({self._mac})", + manufacturer="Petoneer", + model="SmartDot", + ) diff --git a/custom_components/petoneer_smartdot/translations/en.json b/custom_components/petoneer_smartdot/translations/en.json new file mode 100644 index 0000000..374bed7 --- /dev/null +++ b/custom_components/petoneer_smartdot/translations/en.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "title": "Petoneer SmartDot Bluetooth", + "description": "Control a Petoneer SmartDot devices. Select to scan for devices or enter the MAC address manually.", + "data": { + "entry_method": "Method to be used:" + } + }, + "scan": { + "title": "Petoneer SmartDot Bluetooth", + "description": "Make sure the Petoneer SmartDot device is not connected to other devices or it may not be discoverable (A reset may also help). Are you ready to start scanning?" + }, + "device": { + "title": "Petoneer SmartDot Bluetooth", + "description": "Enter a name for the device and the MAC address for the Petoneer SmartDot device.", + "data": { + "name": "Device Name", + "mac": "MAC address" + } + } + }, + "abort": { + "no_devices_found": "No devices found during this scan. Ensure the Petoneer SmartDot device is not connected to another app." + } + }, + "entity": { + "select": { + "preset": { + "state": { + "small": "Small", + "medium": "Medium", + "large": "Large" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..84893cc --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Petoneer SmartDot", + "render_readme": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2b34531 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.ruff] +line-length = 120 +indent-width = 4 + +target-version = "py312" + +[tool.ruff.lint] +select = ["F", "E", "W", "C90", + "I", "N", "S", "B", "A", + "ISC", "T20", "Q", "PTH"] +ignore = ["A003", "C901", "E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto"