diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71be83b..b8e1f00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,13 +19,13 @@ jobs: - name: Adjust version number run: | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ - custom_components/integration_blueprint/manifest.json + custom_components/smarwi/manifest.json - name: ZIP the integration directory - run: zip integration_blueprint.zip -r ./ - working-directory: custom_components/integration_blueprint + run: zip smarwi.zip -r ./ + working-directory: custom_components/smarwi - name: Upload the ZIP file to the release uses: softprops/action-gh-release@v2 with: - files: custom_components/integration_blueprint/integration_blueprint.zip + files: custom_components/smarwi/smarwi.zip diff --git a/README.adoc b/README.adoc deleted file mode 100644 index 422702e..0000000 --- a/README.adoc +++ /dev/null @@ -1,61 +0,0 @@ -= Notice - -The component and platforms in this repository are not meant to be used by a user, but as a “blueprint” that custom component developers can build upon, to make more awesome stuff. - -HAVE FUN! 😎 - - -== Why? - -This is simple, by having custom_components look (README + structure) the same it is easier for developers to help each other and for users to start using them. - -If you are a developer and you want to add things to this “blueprint” that you think more developers will have use for, please open a PR to add it :) - - -== What? - -This repository contains multiple files, here is a overview: - -|=== -| File | Purpose | Documentation - -| `.vscode/tasks.json` -| Tasks for the devcontainer. -| https://code.visualstudio.com/docs/editor/tasks[Documentation] - -| `custom_components/integration_blueprint/*` -| Integration files, this is where everything happens. -| https://developers.home-assistant.io/docs/creating_component_index[Documentation] - -| `LICENSE` -| The license file for the project. -| https://help.github.com/en/github/creating-cloning-and-archiving-repositories/licensing-a-repository[Documentation] - -| `README.adoc` -| The file you are reading now, should contain info about the integration, installation and configuration instructions. -| https://help.github.com/en/github/writing-on-github/basic-writing-and-formatting-syntax[Documentation] - -| `requirements.txt` -| Python packages used for development/lint/testing this integration. -| https://pip.pypa.io/en/stable/user_guide/#requirements-files[Documentation] -|=== - - -== How? - -. Create a new repository in GitHub, using this repository as a template by clicking the “Use this template” button in the GitHub UI. -. Open your new repository in Visual Studio Code. -. Rename all instances of the `integration_blueprint` to `custom_components/` (e.g. `custom_components/awesome_integration`). -. Rename all instances of the `Integration Blueprint` to `` (e.g. `Awesome Integration`). -. Run the link:scripts/develop[`scripts/develop`] to start HA and test out your new integration. - - -== Next steps - -These are some next steps you may want to look into: - -* Add tests to your integration, https://github.com/MatthewFlamm/pytest-homeassistant-custom-component[`pytest-homeassistant-custom-component`] can help you get started. -* Add brand images (logo/icon) to https://github.com/home-assistant/brands. -* Create your first release. -* Share your integration on the https://community.home-assistant.io/[Home Assistant Forum]. -* Submit your integration to the https://hacs.xyz/docs/publish/start[HACS]. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aab4523 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# SMARWI Integration + +[![GitHub Release][releases-shield]][releases] + +Home Assistant integration for Vektiva [SMARWI][smarwi-website] window opener (actuator). + +It uses a local MQTT broker, no cloud service is required. + + +## Installation + +### Using Home Assistant Community Store (HACS) + +1. Ensure that [HACS] is installed. +1. Search for and install the “SMARWI” integration. +1. Restart Home Assistant. + + +### Manually + +1. Download `smarwi.zip` from the [latest release][latest-release]. +1. Unpack `smarwi.zip` and copy the `custom_components/smarwi` directory into the `custom_components` directory of your Home Assistant installation. +1. Restart Home Assistant. + + +## Configuration + +### 1. Set up MQTT broker + +1. Set up your MQTT broker, if you don’t already have one (see [Choose an MQTT broker][choose-mqtt-broker] if you’re unsure). +1. [Add the MQTT integration][my-hass-mqtt] to your Home Assistant instance (see [mqtt-integration][documentation]). + + +### 2. Prepare SMARWI devices + +For each SMARWI device: + +1. Connect to your SMARWI device and open its web interface in the browser (see section 6.1.2 in the [SMARWI manual][smarwi-manual]). +1. Go to **Settings > Advanced**, fill domain name or IP address of your local MQTT broker (see above), and click **Save**. +1. Go to **Basic** and fill in the following: + - **Device name** – this will be used in Home Assistant to identify each SMARWI device; + - **Remote ID** – choose any name (no registration needed), but the same for all your SMARWI devices, it will be used as a prefix for MQTT topics (`ion//%/+`); + - **Remote Key** – leave empty; + - **Wifi Mode** – change to `Client`; + - **Select Wifi network** – select SSID of your Wi-Fi network; + - **Wifi Password** – password for your Wi-Fi network (max 32 characters). +1. Click on **Save**. + +SMARWI should be connected to your Wi-Fi network and MQTT broker now. + + +### 3. Add integration + +1. Browse to your Home Assistant instance. +1. Go to [**Settings > Devices & Services**][my-hass-integrations]. +1. In the bottom right corner, select the [⊕ Add Integration][my-hass-smarwi] button. +1. From the list, select **SMARWI**. +1. Fill in the **Remote ID** that you choose for your SMARWI devices (see above). + +The integration should now automatically detect all your SMARWI devices. + + +## Entities + +The integration will create the following entities for each discovered SMARWI device. + +| Name | Platform | Category | Description +| ------------------- | ------------- | ---------- | --------------------------------------------------------------------------| +| cover | cover | | Control the window tilt position (open, close, stop, set position). | +| ridge_fix | switch | | Fix or release the ridge (can be used only if the motor is currently not |moving). +| ridge_inside | binary_sensor | diagnostic | Shows if the ridge is inside the device (i.e. it’s operational). | +| rssi | sensor | diagnostic | Monitor WiFi signal strength (disabled by default). | +| calibrated_distance | number | config | Set calibrated distance (finetune setting, disabled by default). | +| closed_hold_power | number | config | Set closed holding power (finetune setting). | +| closed_position | number | config | Set window closed position finetune (finetune setting). | +| frame_power | number | config | Set near frame power (finetune setting). | +| frame_speed | number | config | Set near frame speed (finetune setting). | +| lock_err_trigger | number | config | Set window locked error trigger (finetune setting). | +| max_open_position | number | config | Set maximum open position (finetune setting). | +| move_power | number | config | Set movement power (finetune setting). | +| move_speed | number | config | Set movement speed (finetune setting). | +| opened_hold_power | number | config | Set opened holding power (finetune setting). | + +The “config” entities cannot be controlled via MQTT, so the integration connects to the IP address of the device (reported on MQTT) via HTTP protocol. + + +## Screenshots + +Screenshot of the device dashboard + +Screenshot of the cover control in position mode + +Screenshot of the cover control in button mode + + +## Resources + +- [SMARWI product website][smarwi-website] +- [SMARWI user manual][smarwi-manual] +- [SMARWI API documentation][smarwi-api-doc] + + +## License + +This project is licensed under the [MIT License]. + + +[releases]: https://github.com/jirutka/hass-smarwi/releases +[latest-release]: https://github.com/jirutka/hass-smarwi/releases/latest +[releases-shield]: https://img.shields.io/github/release/jirutka/hass-smarwi.svg?style=flat-square +[HACS]: https://hacs.xyz/ +[smarwi-website]: https://vektiva.com/en/smarwi/ +[smarwi-manual]: https://vektiva.com/downloads/SMARWI_manual_EN.pdf +[smarwi-api-doc]: https://vektiva.gitlab.io/vektivadocs/en/api/index.html +[my-hass-integrations]: https://my.home-assistant.io/redirect/integrations/ +[my-hass-mqtt]: https://my.home-assistant.io/redirect/config_flow_start/?domain=mqtt +[my-hass-smarwi]: https://my.home-assistant.io/redirect/config_flow_start/?domain=smarwi +[choose-mqtt-broker]: https://www.home-assistant.io/integrations/mqtt/#choose-an-mqtt-broker +[mqtt-integration]: https://www.home-assistant.io/integrations/mqtt/ +[MIT License]: https://opensource.org/license/MIT diff --git a/README_EXAMPLE.md b/README_EXAMPLE.md deleted file mode 100644 index 59b5148..0000000 --- a/README_EXAMPLE.md +++ /dev/null @@ -1,30 +0,0 @@ -# Integration Blueprint - -[![GitHub Release][releases-shield]][releases] - -_Integration to integrate with [integration_blueprint][integration_blueprint]._ - -**This integration will set up the following platforms.** - -Platform | Description --- | -- -`binary_sensor` | Show something `True` or `False`. -`sensor` | Show info from blueprint API. -`switch` | Switch something `True` or `False`. - -## Installation - -1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). -1. If you do not have a `custom_components` directory (folder) there, you need to create it. -1. In the `custom_components` directory (folder) create a new folder called `integration_blueprint`. -1. Download _all_ the files from the `custom_components/integration_blueprint/` directory (folder) in this repository. -1. Place the files you downloaded in the new directory (folder) you created. -1. Restart Home Assistant -1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Integration blueprint" - - -*** - -[integration_blueprint]: https://github.com/ludeeus/integration_blueprint -[releases-shield]: https://img.shields.io/github/release/ludeeus/integration_blueprint.svg?style=flat-square -[releases]: https://github.com/ludeeus/integration_blueprint/releases diff --git a/config/configuration.yaml b/config/configuration.yaml index 8c0d4e4..2d708bf 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -5,4 +5,4 @@ default_config: logger: default: info logs: - custom_components.integration_blueprint: debug + custom_components.smarwi: debug diff --git a/custom_components/integration_blueprint/__init__.py b/custom_components/integration_blueprint/__init__.py deleted file mode 100644 index 9431aa2..0000000 --- a/custom_components/integration_blueprint/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Custom integration to integrate integration_blueprint with Home Assistant. - -For more details about this integration, please refer to -https://github.com/ludeeus/integration_blueprint -""" - -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .api import IntegrationBlueprintApiClient -from .const import DOMAIN -from .coordinator import BlueprintDataUpdateCoordinator - -PLATFORMS: list[Platform] = [ - Platform.SENSOR, - Platform.BINARY_SENSOR, - Platform.SWITCH, -] - - -# https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator = BlueprintDataUpdateCoordinator( - hass=hass, - client=IntegrationBlueprintApiClient( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - session=async_get_clientsession(hass), - ), - ) - # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities - await coordinator.async_config_entry_first_refresh() - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle removal of an entry.""" - if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unloaded - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) diff --git a/custom_components/integration_blueprint/api.py b/custom_components/integration_blueprint/api.py deleted file mode 100644 index a738040..0000000 --- a/custom_components/integration_blueprint/api.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Sample API Client.""" -from __future__ import annotations - -import asyncio -import socket - -import aiohttp -import async_timeout - - -class IntegrationBlueprintApiClientError(Exception): - """Exception to indicate a general API error.""" - - -class IntegrationBlueprintApiClientCommunicationError( - IntegrationBlueprintApiClientError -): - """Exception to indicate a communication error.""" - - -class IntegrationBlueprintApiClientAuthenticationError( - IntegrationBlueprintApiClientError -): - """Exception to indicate an authentication error.""" - - -class IntegrationBlueprintApiClient: - """Sample API Client.""" - - def __init__( - self, - username: str, - password: str, - session: aiohttp.ClientSession, - ) -> None: - """Sample API Client.""" - self._username = username - self._password = password - self._session = session - - async def async_get_data(self) -> any: - """Get data from the API.""" - return await self._api_wrapper( - method="get", url="https://jsonplaceholder.typicode.com/posts/1" - ) - - async def async_set_title(self, value: str) -> any: - """Get data from the API.""" - return await self._api_wrapper( - method="patch", - url="https://jsonplaceholder.typicode.com/posts/1", - data={"title": value}, - headers={"Content-type": "application/json; charset=UTF-8"}, - ) - - async def _api_wrapper( - self, - method: str, - url: str, - data: dict | None = None, - headers: dict | None = None, - ) -> any: - """Get information from the API.""" - try: - async with async_timeout.timeout(10): - response = await self._session.request( - method=method, - url=url, - headers=headers, - json=data, - ) - if response.status in (401, 403): - raise IntegrationBlueprintApiClientAuthenticationError( - "Invalid credentials", - ) - response.raise_for_status() - return await response.json() - - except asyncio.TimeoutError as exception: - raise IntegrationBlueprintApiClientCommunicationError( - "Timeout error fetching information", - ) from exception - except (aiohttp.ClientError, socket.gaierror) as exception: - raise IntegrationBlueprintApiClientCommunicationError( - "Error fetching information", - ) from exception - except Exception as exception: # pylint: disable=broad-except - raise IntegrationBlueprintApiClientError( - "Something really wrong happened!" - ) from exception diff --git a/custom_components/integration_blueprint/binary_sensor.py b/custom_components/integration_blueprint/binary_sensor.py deleted file mode 100644 index fff5b21..0000000 --- a/custom_components/integration_blueprint/binary_sensor.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Binary sensor platform for integration_blueprint.""" -from __future__ import annotations - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) - -from .const import DOMAIN -from .coordinator import BlueprintDataUpdateCoordinator -from .entity import IntegrationBlueprintEntity - -ENTITY_DESCRIPTIONS = ( - BinarySensorEntityDescription( - key="integration_blueprint", - name="Integration Blueprint Binary Sensor", - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), -) - - -async def async_setup_entry(hass, entry, async_add_devices): - """Set up the binary_sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices( - IntegrationBlueprintBinarySensor( - coordinator=coordinator, - entity_description=entity_description, - ) - for entity_description in ENTITY_DESCRIPTIONS - ) - - -class IntegrationBlueprintBinarySensor(IntegrationBlueprintEntity, BinarySensorEntity): - """integration_blueprint binary_sensor class.""" - - def __init__( - self, - coordinator: BlueprintDataUpdateCoordinator, - entity_description: BinarySensorEntityDescription, - ) -> None: - """Initialize the binary_sensor class.""" - super().__init__(coordinator) - self.entity_description = entity_description - - @property - def is_on(self) -> bool: - """Return true if the binary_sensor is on.""" - return self.coordinator.data.get("title", "") == "foo" diff --git a/custom_components/integration_blueprint/config_flow.py b/custom_components/integration_blueprint/config_flow.py deleted file mode 100644 index a474163..0000000 --- a/custom_components/integration_blueprint/config_flow.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Adds config flow for Blueprint.""" -from __future__ import annotations - -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import selector -from homeassistant.helpers.aiohttp_client import async_create_clientsession - -from .api import ( - IntegrationBlueprintApiClient, - IntegrationBlueprintApiClientAuthenticationError, - IntegrationBlueprintApiClientCommunicationError, - IntegrationBlueprintApiClientError, -) -from .const import DOMAIN, LOGGER - - -class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Config flow for Blueprint.""" - - VERSION = 1 - - async def async_step_user( - self, - user_input: dict | None = None, - ) -> config_entries.FlowResult: - """Handle a flow initialized by the user.""" - _errors = {} - if user_input is not None: - try: - await self._test_credentials( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - ) - except IntegrationBlueprintApiClientAuthenticationError as exception: - LOGGER.warning(exception) - _errors["base"] = "auth" - except IntegrationBlueprintApiClientCommunicationError as exception: - LOGGER.error(exception) - _errors["base"] = "connection" - except IntegrationBlueprintApiClientError as exception: - LOGGER.exception(exception) - _errors["base"] = "unknown" - else: - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data=user_input, - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_USERNAME, - default=(user_input or {}).get(CONF_USERNAME), - ): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.TEXT - ), - ), - vol.Required(CONF_PASSWORD): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.PASSWORD - ), - ), - } - ), - errors=_errors, - ) - - async def _test_credentials(self, username: str, password: str) -> None: - """Validate credentials.""" - client = IntegrationBlueprintApiClient( - username=username, - password=password, - session=async_create_clientsession(self.hass), - ) - await client.async_get_data() diff --git a/custom_components/integration_blueprint/const.py b/custom_components/integration_blueprint/const.py deleted file mode 100644 index 66c28f3..0000000 --- a/custom_components/integration_blueprint/const.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Constants for integration_blueprint.""" -from logging import Logger, getLogger - -LOGGER: Logger = getLogger(__package__) - -NAME = "Integration blueprint" -DOMAIN = "integration_blueprint" -VERSION = "0.0.0" -ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" diff --git a/custom_components/integration_blueprint/coordinator.py b/custom_components/integration_blueprint/coordinator.py deleted file mode 100644 index d427a1a..0000000 --- a/custom_components/integration_blueprint/coordinator.py +++ /dev/null @@ -1,49 +0,0 @@ -"""DataUpdateCoordinator for integration_blueprint.""" -from __future__ import annotations - -from datetime import timedelta - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import ( - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.exceptions import ConfigEntryAuthFailed - -from .api import ( - IntegrationBlueprintApiClient, - IntegrationBlueprintApiClientAuthenticationError, - IntegrationBlueprintApiClientError, -) -from .const import DOMAIN, LOGGER - - -# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities -class BlueprintDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the API.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - client: IntegrationBlueprintApiClient, - ) -> None: - """Initialize.""" - self.client = client - super().__init__( - hass=hass, - logger=LOGGER, - name=DOMAIN, - update_interval=timedelta(minutes=5), - ) - - async def _async_update_data(self): - """Update data via library.""" - try: - return await self.client.async_get_data() - except IntegrationBlueprintApiClientAuthenticationError as exception: - raise ConfigEntryAuthFailed(exception) from exception - except IntegrationBlueprintApiClientError as exception: - raise UpdateFailed(exception) from exception diff --git a/custom_components/integration_blueprint/entity.py b/custom_components/integration_blueprint/entity.py deleted file mode 100644 index 4325227..0000000 --- a/custom_components/integration_blueprint/entity.py +++ /dev/null @@ -1,25 +0,0 @@ -"""BlueprintEntity class.""" -from __future__ import annotations - -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ATTRIBUTION, DOMAIN, NAME, VERSION -from .coordinator import BlueprintDataUpdateCoordinator - - -class IntegrationBlueprintEntity(CoordinatorEntity): - """BlueprintEntity class.""" - - _attr_attribution = ATTRIBUTION - - def __init__(self, coordinator: BlueprintDataUpdateCoordinator) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_unique_id = coordinator.config_entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=NAME, - model=VERSION, - manufacturer=NAME, - ) diff --git a/custom_components/integration_blueprint/manifest.json b/custom_components/integration_blueprint/manifest.json deleted file mode 100644 index e7a9452..0000000 --- a/custom_components/integration_blueprint/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "integration_blueprint", - "name": "Integration blueprint", - "codeowners": [ - "@ludeeus" - ], - "config_flow": true, - "documentation": "https://github.com/ludeeus/integration_blueprint", - "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/ludeeus/integration_blueprint/issues", - "version": "0.0.0" -} diff --git a/custom_components/integration_blueprint/sensor.py b/custom_components/integration_blueprint/sensor.py deleted file mode 100644 index 06201fe..0000000 --- a/custom_components/integration_blueprint/sensor.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Sensor platform for integration_blueprint.""" -from __future__ import annotations - -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription - -from .const import DOMAIN -from .coordinator import BlueprintDataUpdateCoordinator -from .entity import IntegrationBlueprintEntity - -ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( - key="integration_blueprint", - name="Integration Sensor", - icon="mdi:format-quote-close", - ), -) - - -async def async_setup_entry(hass, entry, async_add_devices): - """Set up the sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices( - IntegrationBlueprintSensor( - coordinator=coordinator, - entity_description=entity_description, - ) - for entity_description in ENTITY_DESCRIPTIONS - ) - - -class IntegrationBlueprintSensor(IntegrationBlueprintEntity, SensorEntity): - """integration_blueprint Sensor class.""" - - def __init__( - self, - coordinator: BlueprintDataUpdateCoordinator, - entity_description: SensorEntityDescription, - ) -> None: - """Initialize the sensor class.""" - super().__init__(coordinator) - self.entity_description = entity_description - - @property - def native_value(self) -> str: - """Return the native value of the sensor.""" - return self.coordinator.data.get("body") diff --git a/custom_components/integration_blueprint/switch.py b/custom_components/integration_blueprint/switch.py deleted file mode 100644 index 33340a2..0000000 --- a/custom_components/integration_blueprint/switch.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Switch platform for integration_blueprint.""" -from __future__ import annotations - -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription - -from .const import DOMAIN -from .coordinator import BlueprintDataUpdateCoordinator -from .entity import IntegrationBlueprintEntity - -ENTITY_DESCRIPTIONS = ( - SwitchEntityDescription( - key="integration_blueprint", - name="Integration Switch", - icon="mdi:format-quote-close", - ), -) - - -async def async_setup_entry(hass, entry, async_add_devices): - """Set up the sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices( - IntegrationBlueprintSwitch( - coordinator=coordinator, - entity_description=entity_description, - ) - for entity_description in ENTITY_DESCRIPTIONS - ) - - -class IntegrationBlueprintSwitch(IntegrationBlueprintEntity, SwitchEntity): - """integration_blueprint switch class.""" - - def __init__( - self, - coordinator: BlueprintDataUpdateCoordinator, - entity_description: SwitchEntityDescription, - ) -> None: - """Initialize the switch class.""" - super().__init__(coordinator) - self.entity_description = entity_description - - @property - def is_on(self) -> bool: - """Return true if the switch is on.""" - return self.coordinator.data.get("title", "") == "foo" - - async def async_turn_on(self, **_: any) -> None: - """Turn on the switch.""" - await self.coordinator.api.async_set_title("bar") - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **_: any) -> None: - """Turn off the switch.""" - await self.coordinator.api.async_set_title("foo") - await self.coordinator.async_request_refresh() diff --git a/custom_components/integration_blueprint/translations/en.json b/custom_components/integration_blueprint/translations/en.json deleted file mode 100644 index 63d6840..0000000 --- a/custom_components/integration_blueprint/translations/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "step": { - "user": { - "description": "If you need help with the configuration have a look here: https://github.com/ludeeus/integration_blueprint", - "data": { - "username": "Username", - "password": "Password" - } - } - }, - "error": { - "auth": "Username/Password is wrong.", - "connection": "Unable to connect to the server.", - "unknown": "Unknown error occurred." - } - } -} diff --git a/custom_components/smarwi/__init__.py b/custom_components/smarwi/__init__.py new file mode 100644 index 0000000..20008ed --- /dev/null +++ b/custom_components/smarwi/__init__.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +"""Integration for Vektiva SMARWI window opener.""" + +from typing import Any, cast # type:ignore[Any] + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import CONF_REMOTE_ID, DOMAIN, LOGGER, SIGNAL_DISCOVERY_NEW +from .device import SmarwiDevice + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.COVER, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SMARWI from a config entry.""" + + # Make sure MQTT integration is enabled and the client is available. + if not await mqtt.async_wait_for_mqtt_client(hass): + LOGGER.error("MQTT integration is not available") + return False + + remote_id: str = entry.data[CONF_REMOTE_ID] # pyright:ignore[reportAny] + entry_id: str = entry.entry_id # pyright:ignore[reportAny] + hass_data: dict[str, SmarwiDevice] = hass.data.setdefault( # pyright:ignore[reportAny] + DOMAIN, {} + ).setdefault(entry_id, {}) + + async def device_discovery(msg: mqtt.ReceiveMessage) -> None: + # Topic looks like ion//%/status. + device_id = msg.topic.split("/")[2].removeprefix("%") + + # Check if this device is already known + if device_id in hass_data: + return + + LOGGER.info(f"Discovered new SMARWI device: {device_id}") + + hass_data[device_id] = device = SmarwiDevice(hass, entry, device_id) + await device.async_init() + + async_dispatcher_send(hass, SIGNAL_DISCOVERY_NEW, entry_id, device_id) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Discover SMARWI devices with the configured REMOTE ID. + LOGGER.debug(f"Subscribing to ion/{remote_id}/+/online") + entry.async_on_unload( + await mqtt.async_subscribe(hass, f"ion/{remote_id}/+/online", device_discovery) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + hass_data = cast(dict[str, Any], hass.data[DOMAIN]) # pyright:ignore[reportAny] + + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass_data[cast(str, entry.entry_id)] # pyright:ignore[reportAny] + + return unloaded diff --git a/custom_components/smarwi/binary_sensor.py b/custom_components/smarwi/binary_sensor.py new file mode 100644 index 0000000..dc79ca1 --- /dev/null +++ b/custom_components/smarwi/binary_sensor.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from typing_extensions import override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SIGNAL_DISCOVERY_NEW +from .device import SmarwiDeviceProp, SmarwiDevice +from .entity import SmarwiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SMARWI binary sensor entities based on a config entry.""" + hass_data: dict[str, SmarwiDevice] = hass.data[DOMAIN][entry.entry_id] # pyright:ignore[reportAny] + + async def async_discover_device(entry_id: str, device_id: str) -> None: + if entry_id != entry.entry_id: # pyright:ignore[reportAny] + return # not for us + assert hass_data[device_id] is not None + async_add_entities([SmarwiRidgeInsideBinarySensor(hass_data[device_id])], True) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DISCOVERY_NEW, async_discover_device) + ) + + +class SmarwiRidgeInsideBinarySensor(SmarwiEntity, BinarySensorEntity): + """Representation of the SMARWI "ridge inside" sensor.""" + + entity_description = BinarySensorEntityDescription( + key="ridge_inside", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ) # pyright:ignore[reportCallIssue] + + @override + async def async_handle_update(self, changed_props: set[SmarwiDeviceProp]) -> None: + if SmarwiDeviceProp.RIDGE_INSIDE in changed_props: + self._attr_is_on = not self.device.ridge_inside + self.async_write_ha_state() diff --git a/custom_components/smarwi/config_flow.py b/custom_components/smarwi/config_flow.py new file mode 100644 index 0000000..2e553fd --- /dev/null +++ b/custom_components/smarwi/config_flow.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from typing import Any # type:ignore[Any] +from typing_extensions import override + +from homeassistant.config_entries import ConfigFlow, CONN_CLASS_LOCAL_PUSH, FlowResult +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +import voluptuous as vol + +from .const import CONF_REMOTE_ID, DOMAIN, NAME + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_REMOTE_ID): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT), + ), + } +) + + +class SmarwiConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for SMARWI integration.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH + + @override + 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 not None: + return self.async_create_entry(title=NAME, data=user_input) + + # If there is no user input, show the form again. + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) diff --git a/custom_components/smarwi/const.py b/custom_components/smarwi/const.py new file mode 100644 index 0000000..7f50e43 --- /dev/null +++ b/custom_components/smarwi/const.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from logging import Logger, getLogger +from typing import Final + +LOGGER: Final[Logger] = getLogger(__package__) + +NAME: Final = "SMARWI" +DOMAIN: Final = "smarwi" + +DEVICE_INFO_MANUFACTURER: Final = "Vektiva" +DEVICE_INFO_MODEL: Final = "SMARWI" + +CONF_REMOTE_ID: Final = "remote_id" + +NEAR_FRAME_POSITION: Final = 5 +"""The window position to be reported when it's between the frame and the frame sensor.""" + +SIGNAL_DISCOVERY_NEW: Final = f"{DOMAIN}.discovery_new" +"""Signal dispatched when a new SMARWI device is discovered.""" + + +def signal_device_update(device_id: str) -> str: + """Device specific event to signal a change in the status properties or availability.""" + return f"{DOMAIN}.{device_id}_update" diff --git a/custom_components/smarwi/cover.py b/custom_components/smarwi/cover.py new file mode 100644 index 0000000..dffbc6b --- /dev/null +++ b/custom_components/smarwi/cover.py @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from typing import Any # pyright:ignore[reportAny] +from typing_extensions import override + +from homeassistant.components.cover import ( + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOGGER, NEAR_FRAME_POSITION, SIGNAL_DISCOVERY_NEW +from .device import SmarwiDeviceProp, SmarwiDevice +from .entity import SmarwiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SMARWI window cover entities.""" + hass_data: dict[str, SmarwiDevice] = hass.data[DOMAIN][entry.entry_id] # pyright:ignore[reportAny] + + async def async_discover_device(entry_id: str, device_id: str) -> None: + if entry_id != entry.entry_id: # pyright:ignore[reportAny] + return # not for us + assert hass_data[device_id] is not None + async_add_entities([SmarwiCover(hass_data[device_id])]) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DISCOVERY_NEW, async_discover_device) + ) + + +class SmarwiCover(SmarwiEntity, CoverEntity): + """Representation of a SMARWI Window Cover.""" + + entity_description = CoverEntityDescription( + key="cover", + device_class=CoverDeviceClass.WINDOW, + ) # pyright:ignore[reportCallIssue] + + _attr_supported_features = ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + def __init__(self, device: SmarwiDevice): + """Initialize the cover.""" + super().__init__(device) + self._position = -1 # unknown + self._requested_position = -1 # unknown + self._is_moving = False + + @property # superclass uses @cached_property, but that doesn't work here [1] + @override + def available(self) -> bool: # pyright:ignore[reportIncompatibleVariableOverride] + return self._attr_available and not self.device.state_code.is_error() + + @property # superclass uses @cached_property, but that doesn't work here [1] + @override + def is_closed(self) -> bool | None: # pyright:ignore[reportIncompatibleVariableOverride] + return self.device.closed + + @property # superclass uses @cached_property, but that doesn't work here [1] + @override + def is_closing(self) -> bool: # pyright:ignore[reportIncompatibleVariableOverride] + return self._is_moving and self._requested_position < self._position + + @property # superclass uses @cached_property, but that doesn't work here [1] + @override + def is_opening(self) -> bool: # pyright:ignore[reportIncompatibleVariableOverride] + return self._is_moving and self._requested_position > self._position + + @property # superclass uses @cached_property, but that doesn't work here [1] + @override + def current_cover_tilt_position(self) -> int | None: # pyright:ignore[reportIncompatibleVariableOverride] + return self._position if self._position >= 0 else None + + @override + async def async_open_cover_tilt(self, **kwargs: Any) -> None: # pyright:ignore[reportAny] + self._requested_position = 100 + await self.device.async_open() + + @override + async def async_close_cover_tilt(self, **kwargs: Any) -> None: # pyright:ignore[reportAny] + self._requested_position = 0 + await self.device.async_close() + + @override + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: # pyright:ignore[reportAny] + pos = int(kwargs[ATTR_TILT_POSITION]) # pyright:ignore[reportAny] + self._requested_position = pos + await self.device.async_open(pos) + + @override + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: # pyright:ignore[reportAny] + # If the motor is not moving, "stop" releases the ridge. + if self.device.state_code.is_idle(): + return None + + self._requested_position = -1 # unknown + self._position = -1 # unknown + await self.device.async_stop() + + @override + async def async_handle_update(self, changed_props: set[SmarwiDeviceProp]) -> None: + if not changed_props & {SmarwiDeviceProp.CLOSED, SmarwiDeviceProp.STATE_CODE}: + return + + state = self.device.state_code + if state.is_error(): + self._is_moving = False + self._position = -1 + self._requested_position = -1 + elif state.is_moving(): + self._is_moving = True + if state.is_near_frame(): + self._position = NEAR_FRAME_POSITION + elif state.is_idle(): + if self.device.closed: + self._is_moving = False + self._position = 0 + self._requested_position = 0 + elif self._is_moving: + self._is_moving = False + if self._requested_position >= 0: + self._position = self._requested_position + self._requested_position = -1 + else: + self._position = -1 + + LOGGER.debug( + f"[{self.device.name}] id={self.device.id}, state_code={state.name}, closed={self.device.closed}, _is_moving={self._is_moving}, _position={self._position}, _requested_position={self._requested_position}" + ) + self._attr_extra_state_attributes["state_code"] = state.name + self.async_write_ha_state() + + +# Footnotes: +# +# [1] @cached_property is supposed to be reset on state update, but it doesn't +# work here, even when I provide SmarwiCover(..., cached_properties={...}). diff --git a/custom_components/smarwi/device.py b/custom_components/smarwi/device.py new file mode 100644 index 0000000..793c48e --- /dev/null +++ b/custom_components/smarwi/device.py @@ -0,0 +1,405 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from __future__ import annotations + +from enum import IntEnum, StrEnum +from functools import cached_property +import socket +import struct +from typing import TYPE_CHECKING, Any # pyright:ignore[reportAny] +from homeassistant.helpers.entity import DeviceInfo +from typing_extensions import override + +import aiohttp +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_REMOTE_ID, + DEVICE_INFO_MANUFACTURER, + DEVICE_INFO_MODEL, + DOMAIN, + LOGGER, + signal_device_update, +) + +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + +__all__ = ["FinetuneSetting", "SmarwiDevice", "SmarwiDeviceProp", "StateCode"] + + +class SmarwiDeviceProp(StrEnum): + """Updatable properties of SmarwiDevice.""" + + # status properties + NAME = "cid" + RIDGE_FIXED = "fix" + FW_VERSION = "fw" + IP_ADDRESS = "ip" + CLOSED = "pos" + RIDGE_INSIDE = "ro" + RSSI = "rssi" + STATE_CODE = "s" + # others + AVAILABLE = "available" + FINETUNE_SETTINGS = "finetune_settings" + + @override + def __hash__(self) -> int: + return hash(self.name) + + +class FinetuneSetting(StrEnum): + """SMARWI finetune setting keys.""" + + MAX_OPEN_POSITION = "vpct" # Maximum open position + MOVE_SPEED = "ospd" # Movement speed + FRAME_SPEED = "ofspd" # Near frame speed + MOVE_POWER = "orpwr" # Movement power + FRAME_POWER = "ofpwr" # Near frame power + CLOSED_HOLD_POWER = "ohcpwr" # Closed holding power + OPENED_HOLD_POWER = "ohopwr" # Opened holding power + CLOSED_POSITION = "hdist" # Window closed position finetune + LOCK_ERR_TRIGGER = "lwid" # "Window locked" error trigger + CALIBRATED_DISTANCE = "cfdist" # Calibrated distance + + +class StateCode(IntEnum): + """SMARWI state codes.""" + + CALIBRATION = -1 # Calibration in progress + UNKNOWN = 0 # Fallback for unknown code + ERR_WINDOW_LOCKED = 10 # Window is locked to frame + ERR_MOVE_TIMEOUT = 20 # Operation move to frame sensor from ventilation timeout + ERR_WINDOW_HORIZ = 30 # Indicates, that window is opened in horizontal position + OPENING_START = 200 # Moving to frame sensor position within opening phase. + OPENING = 210 # Opening phase lasts until target ventilation position is reached + REOPEN_START = 212 # Reopen in executed when open operation is invoked while window position is between frame sensor and ventilation distance + REOPEN_PHASE = 214 # Window reached frame sensor + REOPEN_FINAL = 216 # Final phase of reopen operation, window moved to target ventilation position + CLOSING_START = 220 # Moving to frame sensor position within closing phase + CLOSING = 230 # Closing phase lasts until target closed position is reached + CLOSING_NICE = 231 # Closing step by step until obstacle detected? + RECLOSE_START = 232 # Re-closing starts when close operation in invoked while window position is between frame and frame sensor + RECLOSE_PHASE = 234 # Window moved after frame sensor + IDLE = 250 + + @override + @classmethod + def _missing_(cls, value: object) -> StateCode: + return cls.UNKNOWN + + def is_error(self) -> bool: + """Return True if the state indicate an error (or in calibration).""" + return self.value < 200 + + def is_idle(self) -> bool: + """Return True if the window is in idle.""" + return self == StateCode.IDLE + + def is_moving(self) -> bool: + """Return True if the window is moving.""" + return 199 < self.value < 250 + + def is_near_frame(self) -> bool: + """Return True if the window is between the frame and the frame sensor.""" + # TODO: add more? + return self in ( + StateCode.OPENING_START, + StateCode.REOPEN_PHASE, + StateCode.CLOSING, + StateCode.CLOSING_NICE, + ) + + +class SmarwiDevice: + """This class handles communication with a single SMARWI device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + device_id: str, + ) -> None: + """Initialize. Run `.async_init()` afterwards.""" + super().__init__() + config: dict[str, Any] = entry.data # pyright:ignore[reportAny] + + self._hass = hass + self._config_entry = entry + self._id = device_id + + self._remote_id: str = config[CONF_REMOTE_ID] + self._base_topic = f"ion/{self._remote_id}/%{device_id}" + self._available = False + self._status: dict[SmarwiDeviceProp, str] = {} + self._finetune_settings = FinetuneSettings(self) + + @cached_property + def id(self) -> str: + """Return device ID (serial).""" + return self._id + + @cached_property + def basic_device_info(self) -> DeviceInfo: + """Return immutable part of the DeviceInfo.""" + return DeviceInfo( + identifiers={(DOMAIN, self.id)}, + manufacturer=DEVICE_INFO_MANUFACTURER, + model=DEVICE_INFO_MODEL, + ) + + @cached_property + def finetune_settings(self) -> FinetuneSettings: + """Return Finetune settings.""" + return self._finetune_settings + + @cached_property + def signal_update(self) -> str: + """Device specific event to signal a change in the status properties or availability.""" + return signal_device_update(self.id) + + @property + def available(self) -> bool: + """Return `True` if the device is available (online).""" + return self._available + + @property + def closed(self) -> bool | None: + """Return `True` if the window is open, `False` if closed.""" + if SmarwiDeviceProp.CLOSED in self._status: + return self._status[SmarwiDeviceProp.CLOSED] == "c" + + @property + def fw_version(self) -> str | None: + """Return the firmware version of the device.""" + return self._status.get(SmarwiDeviceProp.FW_VERSION) + + @property + def ip_address(self) -> str | None: + """Return the IPv4 address of the device.""" + if SmarwiDeviceProp.IP_ADDRESS in self._status: + return parse_ipv4(int(self._status[SmarwiDeviceProp.IP_ADDRESS])) + + @property + def name(self) -> str | None: + """Return the device name (CID) configured in the SMARWI settings.""" + return self._status.get(SmarwiDeviceProp.NAME) + + @property + def ridge_fixed(self) -> bool: + """Return `True` if the ridge is fixed, i.e. the window cannot be moved by hand.""" + return self._status.get(SmarwiDeviceProp.RIDGE_FIXED) == "1" + + @property + def ridge_inside(self) -> bool: + """Return `True` if the ridge is inside the device, i.e. it can be controlled.""" + return self._status.get(SmarwiDeviceProp.RIDGE_INSIDE) == "0" + + @property + def rssi(self) -> int | None: + """Return the WiFi signal strength.""" + if SmarwiDeviceProp.RSSI in self._status: + return int(self._status[SmarwiDeviceProp.RSSI]) + + @property + def state_code(self) -> StateCode: + """Return the SMARWI state code (parameter `s`).""" + return StateCode(int(self._status.get(SmarwiDeviceProp.STATE_CODE) or 0)) + + async def async_init(self) -> None: + """Connect to the device.""" + + async def handle_status_message(msg: mqtt.ReceiveMessage) -> None: + status = decode_keyval(msg.payload) # pyright:ignore[reportAny] + status = { + SmarwiDeviceProp(k): v + for k, v in status.items() + if k in list(SmarwiDeviceProp) + } + changed_props = { + name + for name in SmarwiDeviceProp + if self._status.get(name) != status.get(name) + } + LOGGER.debug( + f"Received message from {self._base_topic}/status:\n{msg.payload}\nChanged properties: {[e.name for e in changed_props]}" # pyright:ignore[reportAny] + ) + self._status = status + + if changed_props & { + SmarwiDeviceProp.NAME, + SmarwiDeviceProp.IP_ADDRESS, + SmarwiDeviceProp.FW_VERSION, + }: + await self._async_update_device_registry() + + if SmarwiDeviceProp.IP_ADDRESS in changed_props: + LOGGER.info( + f"[{self.name}] Fetching Finetune settings from http://{self.ip_address}" + ) + await self._finetune_settings.async_update() + + async_dispatcher_send(self._hass, self.signal_update, changed_props) + + async def handle_online_message(msg: mqtt.ReceiveMessage) -> None: + LOGGER.debug( + f"Received message from {self._base_topic}/online: {msg.payload}" # pyright:ignore[reportAny] + ) + if (available := bool(msg.payload == "1")) != self._available: # pyright:ignore[reportAny] + LOGGER.info( + f"[{self.name}] SMARWI {self.id} become {'available' if available else 'unavailable'}" + ) + self._available = available + async_dispatcher_send( + self._hass, self.signal_update, {SmarwiDeviceProp.AVAILABLE} + ) + + self._config_entry.async_on_unload( + await mqtt.async_subscribe( + self._hass, + f"{self._base_topic}/status", + handle_status_message, + ) + ) + self._config_entry.async_on_unload( + await mqtt.async_subscribe( + self._hass, + f"{self._base_topic}/online", + handle_online_message, + ) + ) + + async def async_open(self, position: int = 100) -> None: + """Open the window; position is between 0 and 100 %.""" + if position > 1: + LOGGER.info(f"[{self.name}] Opening window") + await self._async_mqtt_command(f"open;{position}") + else: + await self.async_close() + + async def async_close(self) -> None: + """Close the window.""" + LOGGER.info(f"[{self.name}] Closing window") + await self._async_mqtt_command("close") + + async def async_stop(self) -> None: + """Stop the movement action if moving.""" + # If the motor is not moving, "stop" releases the ridge. + if self.state_code.is_moving(): + LOGGER.info(f"[{self.name}] Stopping movement of window") + await self._async_mqtt_command("stop") + + async def async_toggle_ridge_fixed(self, state: bool) -> None: + """Fix (True) or release (False) the ridge.""" + if state: + if not self.ridge_fixed: + LOGGER.info(f"[{self.name}] Fixing ridge") + await self._async_mqtt_command("stop") + else: + # If the motor is moving, "stop" stops it. + if self.ridge_fixed and self.state_code.is_idle(): + LOGGER.info(f"[{self.name}] Releasing ridge") + await self._async_mqtt_command("stop") + + async def _async_mqtt_command(self, payload: str) -> None: + LOGGER.debug(f"Sending message to {self._base_topic}/cmd: {payload}") + await mqtt.async_publish(self._hass, f"{self._base_topic}/cmd", payload) + + async def _async_update_device_registry(self) -> None: + dev_registry = dr.async_get(self._hass) + + if dev := dev_registry.async_get_device(identifiers={(DOMAIN, self.id)}): + LOGGER.debug(f"Updating SMARWI {self.id} in device registry") + dev_registry.async_update_device( + dev.id, + configuration_url=f"http://{self.ip_address}", + name=self.name, + sw_version=self.fw_version, + serial_number=self.id, + ) # pyright:ignore[reportUnusedCallResult] + + +class FinetuneSettings: + def __init__(self, device: SmarwiDevice) -> None: + super().__init__() + self._device = device + self._data: dict[str, int] = {} + + def get(self, key: FinetuneSetting) -> int | None: + return self._data.get(key.value) + + async def async_set(self, key: FinetuneSetting, value: int) -> None: + if not self._device.ip_address: + return + data = self._data.copy() + data[key.value] = value + LOGGER.info( + f"[{self._device.name}] Changing finetune setting {key.name} from {self._data[key.value]} to {value}" + ) + await http_post_data(self._device.ip_address, "scfa", encode_keyval(data)) + self._data = data # TODO: race condition? + + await self.async_update() + + async def async_update(self) -> None: + """Update Finetune settings from SMARWI via HTTP.""" + assert self._device.ip_address is not None, "ip_address is not known yet" + + data = await http_get(self._device.ip_address, "lcfa") + self._data = { + k: int(v) + for k, v in decode_keyval(data).items() + if k != "cvdist" # cvdist is ready-only + } + async_dispatcher_send( + self._device._hass, # pyright:ignore[reportPrivateUsage] + self._device.signal_update, + {SmarwiDeviceProp.FINETUNE_SETTINGS}, + ) + + +def parse_ipv4(packed_ip: int) -> str: + """Parse IPv4 address represented by 32bit number in Little Endian.""" + return socket.inet_ntoa(struct.pack(" dict[str, str]: + """Parse key:value pairs separated by newlines.""" + return dict(line.split(":", 1) for line in payload.splitlines()) + + +def encode_keyval(data: dict[str, Any]) -> str: + """Encode the given dict as key:value pairs separated by newlines.""" + return "\n".join(f"{k}:{v}" for k, v in data.items()) # pyright:ignore[reportAny] + + +async def http_get(host: str, path: str) -> str: + """Send HTTP GET request and return the response body.""" + LOGGER.debug(f"Sending GET http://{host}/{path}") + async with ( + aiohttp.ClientSession(raise_for_status=True) as http, + http.get(f"http://{host}/{path}") as resp, + ): + data = await resp.text() + LOGGER.debug( + f"Received response for GET http://{host}/{path}: HTTP {resp.status}\n{data}" + ) + return data + + +async def http_post_data(host: str, path: str, data: str) -> None: + """Send HTTP POST request with multipart data as expected by SWARMI.""" + with aiohttp.MultipartWriter("form-data") as mpwriter: + mpwriter.append(data.encode("ascii")).set_content_disposition( + "form-data", name="data", filename="/afile" + ) + LOGGER.debug(f"POST http://{host}/{path}:\n{data}") + async with aiohttp.ClientSession(raise_for_status=True) as http: + await http.post(f"http://{host}/{path}", data=mpwriter) # pyright:ignore[reportUnusedCallResult] diff --git a/custom_components/smarwi/entity.py b/custom_components/smarwi/entity.py new file mode 100644 index 0000000..948ab2b --- /dev/null +++ b/custom_components/smarwi/entity.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from abc import abstractmethod +from typing_extensions import override + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .device import SmarwiDeviceProp, SmarwiDevice + + +class SmarwiEntity(Entity): + """Base class for SMARWI entities.""" + + # NOTE: Do not set _attr_entity_name, it breaks localization! + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: SmarwiDevice) -> None: + """Initialize Entity.""" + super().__init__() + + if self.entity_description and not self.translation_key: + self._attr_translation_key = self.entity_description.key + + assert self.translation_key is not None, "translation_key is not set" + + self.device = device + self._attr_device_info = device.basic_device_info + self._attr_unique_id = f"{device.id}_{self.translation_key}" + self._attr_extra_state_attributes = {} + + @override + async def async_added_to_hass(self) -> None: + async def update_handler(changed_keys: set[SmarwiDeviceProp]) -> None: + if SmarwiDeviceProp.AVAILABLE in changed_keys: + self._attr_available = self.device.available + + await self.async_handle_update(changed_keys) + + if SmarwiDeviceProp.AVAILABLE in changed_keys: + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.device.signal_update, + update_handler, + ) + ) + + @abstractmethod + async def async_handle_update(self, changed_props: set[SmarwiDeviceProp]) -> None: + """When the device data has been updated.""" + pass diff --git a/custom_components/smarwi/manifest.json b/custom_components/smarwi/manifest.json new file mode 100644 index 0000000..3c55289 --- /dev/null +++ b/custom_components/smarwi/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "smarwi", + "name": "SMARWI", + "codeowners": ["@jirutka"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://github.com/jirutka/hass-smarwi", + "integration_type": "device", + "iot_class": "local_push", + "issue_tracker": "https://github.com/jirutka/hass-smarwi/issues", + "requirements": ["aiohttp"], + "version": "0.0.0" + } diff --git a/custom_components/smarwi/number.py b/custom_components/smarwi/number.py new file mode 100644 index 0000000..bfe5222 --- /dev/null +++ b/custom_components/smarwi/number.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from typing_extensions import override + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SIGNAL_DISCOVERY_NEW +from .device import SmarwiDeviceProp, FinetuneSetting, SmarwiDevice +from .entity import SmarwiEntity + + +SETTINGS_ENTITY_DESCRIPTIONS = [ + NumberEntityDescription( + key=FinetuneSetting.CALIBRATED_DISTANCE.name, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + entity_registry_enabled_default=False, + mode=NumberMode.BOX, + ), # pyright:ignore[reportCallIssue] + NumberEntityDescription( + key=FinetuneSetting.CLOSED_POSITION.name, + entity_category=EntityCategory.CONFIG, + native_min_value=-20, + native_max_value=20, + native_step=1, + ), # pyright:ignore[reportCallIssue] + NumberEntityDescription( + key=FinetuneSetting.LOCK_ERR_TRIGGER.name, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=40, + native_step=1, + ), # pyright:ignore[reportCallIssue] + *( + NumberEntityDescription( + key=key.name, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement="%", + ) # pyright:ignore[reportCallIssue] + for key in FinetuneSetting + if key + not in ( + FinetuneSetting.CALIBRATED_DISTANCE, + FinetuneSetting.CLOSED_POSITION, + FinetuneSetting.LOCK_ERR_TRIGGER, + ) + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SMARWI number entities based on a config entry.""" + hass_data: dict[str, SmarwiDevice] = hass.data[DOMAIN][entry.entry_id] # pyright:ignore[reportAny] + + async def async_discover_device(entry_id: str, device_id: str) -> None: + if entry_id != entry.entry_id: # pyright:ignore[reportAny] + return # not for us + assert hass_data[device_id] is not None + + entities = [ + SmarwiConfigNumber(hass_data[device_id], desc) + for desc in SETTINGS_ENTITY_DESCRIPTIONS + ] + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DISCOVERY_NEW, async_discover_device) + ) + + +class SmarwiConfigNumber(SmarwiEntity, NumberEntity): + """Representation of SMARWI setting.""" + + def __init__( + self, device: SmarwiDevice, description: NumberEntityDescription + ) -> None: + """Initialize Entity.""" + self.entity_description = description + self._attr_translation_key = description.key.lower() + super().__init__(device) + self._setting_key = FinetuneSetting[description.key] + + @property # superclass uses @cached_property, but that doesn't work here + @override + def available(self) -> bool: # pyright:ignore[reportIncompatibleVariableOverride] + return self._attr_available and self._attr_native_value is not None + + @override + async def async_set_native_value(self, value: float) -> None: + await self.device.finetune_settings.async_set(self._setting_key, int(value)) + + @override + async def async_handle_update(self, changed_props: set[SmarwiDeviceProp]) -> None: + if SmarwiDeviceProp.FINETUNE_SETTINGS in changed_props: + if ( + value := self.device.finetune_settings.get(self._setting_key) + ) is not None: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/custom_components/smarwi/sensor.py b/custom_components/smarwi/sensor.py new file mode 100644 index 0000000..d169718 --- /dev/null +++ b/custom_components/smarwi/sensor.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from typing_extensions import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SIGNAL_DISCOVERY_NEW +from .device import SmarwiDeviceProp, SmarwiDevice +from .entity import SmarwiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SMARWI sensor entities based on a config entry.""" + hass_data: dict[str, SmarwiDevice] = hass.data[DOMAIN][entry.entry_id] # pyright:ignore[reportAny] + + async def async_discover_device(entry_id: str, device_id: str) -> None: + if entry_id != entry.entry_id: # pyright:ignore[reportAny] + return # not for us + assert hass_data[device_id] is not None + async_add_entities([SmarwiRssiSensor(hass_data[device_id])]) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DISCOVERY_NEW, async_discover_device) + ) + + +class SmarwiRssiSensor(SmarwiEntity, SensorEntity): + """Representation of the SMARWI signal strength sensor.""" + + entity_description = SensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + ) # pyright:ignore[reportCallIssue] + + @override + async def async_handle_update(self, changed_props: set[SmarwiDeviceProp]) -> None: + if SmarwiDeviceProp.RSSI in changed_props: + self._attr_native_value = self.device.rssi + self.async_write_ha_state() diff --git a/custom_components/smarwi/switch.py b/custom_components/smarwi/switch.py new file mode 100644 index 0000000..48f3974 --- /dev/null +++ b/custom_components/smarwi/switch.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2024 Jakub Jirutka +from typing import Any # pyright:ignore[reportAny] +from typing_extensions import override + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SIGNAL_DISCOVERY_NEW +from .device import SmarwiDeviceProp, SmarwiDevice +from .entity import SmarwiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SMARWI switch entities based on a config entry.""" + hass_data: dict[str, SmarwiDevice] = hass.data[DOMAIN][entry.entry_id] # pyright:ignore[reportAny] + + async def async_discover_device(entry_id: str, device_id: str) -> None: + if entry_id != entry.entry_id: # pyright:ignore[reportAny] + return # not for us + assert hass_data[device_id] is not None + async_add_entities([SmarwiRidgeFixedSwitch(hass_data[device_id])]) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DISCOVERY_NEW, async_discover_device) + ) + + +class SmarwiRidgeFixedSwitch(SmarwiEntity, SwitchEntity): + """Representation of the SMARWI fixation sensor/switch.""" + + entity_description = SwitchEntityDescription( + key="ridge_fix", + device_class=SwitchDeviceClass.SWITCH, + ) # pyright:ignore[reportCallIssue] + + @override + async def async_turn_on(self, **_: Any) -> None: + await self.device.async_toggle_ridge_fixed(True) + + @override + async def async_turn_off(self, **_: Any) -> None: + await self.device.async_toggle_ridge_fixed(False) + + @override + async def async_handle_update(self, changed_props: set[SmarwiDeviceProp]) -> None: + if SmarwiDeviceProp.RIDGE_FIXED in changed_props: + self._attr_is_on = self.device.ridge_fixed + self.async_write_ha_state() diff --git a/custom_components/smarwi/translations/en.json b/custom_components/smarwi/translations/en.json new file mode 100644 index 0000000..b54ea4c --- /dev/null +++ b/custom_components/smarwi/translations/en.json @@ -0,0 +1,70 @@ +{ + "config": { + "step": { + "user": { + "description": "Before adding this integration, set up the MQTT integration in Home Assistant and configure your SMARWI devices to use your local MQTT broker.", + "data": { + "remote_id": "Remote ID (configured in SMARWI Settings)" + } + } + } + }, + "entity": { + "binary_sensor": { + "ridge_inside": { + "name": "Ridge inside" + } + }, + "cover": { + "cover": { + "name": "Window" + } + }, + "number": { + "max_open_position": { + "name": "Maximum open position" + }, + "move_speed": { + "name": "Movement speed" + }, + "frame_speed": { + "name": "Near frame speed" + }, + "move_power": { + "name": "Movement power" + }, + "frame_power": { + "name": "Near frame power" + }, + "closed_hold_power": { + "name": "Closed holding power" + }, + "opened_hold_power": { + "name": "Opened holding power" + }, + "closed_position": { + "name": "Window closed position finetune" + }, + "lock_err_trigger": { + "name": "Window locked error trigger" + }, + "calibrated_distance": { + "name": "Calibrated distance" + } + }, + "sensor": { + "rssi": { + "name": "Signal strength" + } + }, + "switch": { + "ridge_fix": { + "name": "Ridge fixed", + "state": { + "on": "Fixed", + "off": "Free" + } + } + } + } +} diff --git a/hacs.json b/hacs.json index ee44c81..a013312 100644 --- a/hacs.json +++ b/hacs.json @@ -1,8 +1,8 @@ { - "name": "Integration blueprint", - "filename": "integration_blueprint.zip", - "hide_default_branch": true, - "homeassistant": "2024.1.0", + "name": "SMARWI", + "zip_release": true, + "filename": "smarwi.zip", "render_readme": true, - "zip_release": true + "hide_default_branch": true, + "homeassistant": "2024.1.0" } diff --git a/images/dark_icon.png b/images/dark_icon.png new file mode 100644 index 0000000..0f3a2e8 Binary files /dev/null and b/images/dark_icon.png differ diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..c3a677c Binary files /dev/null and b/images/icon.png differ diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..457d5eb Binary files /dev/null and b/images/logo.png differ diff --git a/images/screenshot-cover-buttons.png b/images/screenshot-cover-buttons.png new file mode 100644 index 0000000..87fb30f Binary files /dev/null and b/images/screenshot-cover-buttons.png differ diff --git a/images/screenshot-cover-position.png b/images/screenshot-cover-position.png new file mode 100644 index 0000000..63421d1 Binary files /dev/null and b/images/screenshot-cover-position.png differ diff --git a/images/screenshot-device.png b/images/screenshot-device.png new file mode 100644 index 0000000..bea67b2 Binary files /dev/null and b/images/screenshot-device.png differ