commit 5eb4a9b9f3f788abc370bba366bc4b51a4f820f6 Author: Gurkengewuerz Date: Fri Jan 3 22:01:49 2025 +0100 move repo to single repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82d61a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,300 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# PyPI configuration file +.pypirc + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + + +repomix-output.xml +app.db +.ha \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..53da0dc --- /dev/null +++ b/compose.yml @@ -0,0 +1,14 @@ +services: + has: + image: ghcr.io/home-assistant/home-assistant:stable + restart: unless-stopped + ports: + - "8123:8123" + volumes: + - ./custom_components/smart_home:/config/custom_components/smart_home + - .ha/config:/config + - /etc/localtime:/etc/localtime:ro + - /run/dbus:/run/dbus:ro + environment: + - TZ=Europe/Berlin + diff --git a/custom_components/smart_home/__init__.py b/custom_components/smart_home/__init__.py new file mode 100644 index 0000000..987d638 --- /dev/null +++ b/custom_components/smart_home/__init__.py @@ -0,0 +1,63 @@ +"""The Smart Home integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform, CONF_HOST, CONF_PORT, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr + +from .const import ( + DOMAIN, + DATA_COORDINATOR, + DATA_CONFIG, + DEFAULT_PORT, + DEVICE_TYPE_MAPPING, +) +from .coordinator import SmartHomeDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.SWITCH, + Platform.BUTTON, + Platform.COVER, + Platform.SENSOR, + Platform.BINARY_SENSOR, +] + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Smart Home from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + coordinator = SmartHomeDataUpdateCoordinator( + hass, + config=entry.data, + entry_id=entry.entry_id, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + DATA_CONFIG: entry.data, + } + + 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.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + coordinator: SmartHomeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + await coordinator.async_shutdown() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok \ No newline at end of file diff --git a/custom_components/smart_home/binary_sensor.py b/custom_components/smart_home/binary_sensor.py new file mode 100644 index 0000000..2a1fb3b --- /dev/null +++ b/custom_components/smart_home/binary_sensor.py @@ -0,0 +1,39 @@ +"""Support for Smart Home binary sensors.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, DATA_COORDINATOR +from .coordinator import SmartHomeDataUpdateCoordinator +from .entity import SmartHomeEntity + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Smart Home binary sensors.""" + coordinator: SmartHomeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + + entities = [] + for device_id, device in coordinator._devices.items(): + if device["device_type"] == "binary_sensor": + entities.append(SmartHomeBinarySensor(coordinator, device_id)) + + async_add_entities(entities) + +class SmartHomeBinarySensor(SmartHomeEntity, BinarySensorEntity): + """Representation of a Smart Home binary sensor.""" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.device_state.get("state", False) diff --git a/custom_components/smart_home/button.py b/custom_components/smart_home/button.py new file mode 100644 index 0000000..0fc22fd --- /dev/null +++ b/custom_components/smart_home/button.py @@ -0,0 +1,43 @@ +"""Support for Smart Home pushbuttons.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, DATA_COORDINATOR +from .coordinator import SmartHomeDataUpdateCoordinator +from .entity import SmartHomeEntity + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Smart Home buttons.""" + coordinator: SmartHomeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + + entities = [] + #for device_id, device in coordinator._devices.items(): + # if device["device_type"] == "pushbutton": + # entities.append(SmartHomeButton(coordinator, device_id)) + + async_add_entities(entities) + +class SmartHomeButton(SmartHomeEntity, ButtonEntity): + """Representation of a Smart Home button.""" + + async def async_press(self) -> None: + """Press the button.""" + async with aiohttp.ClientSession() as session: + url = f"{self.coordinator.api_url}/api/devices/{self._device_id}/state" + async with session.put(url, json={"state": True}) as response: + if response.status != 200: + _LOGGER.error("Failed to press button: %s", response.status) diff --git a/custom_components/smart_home/config_flow.py b/custom_components/smart_home/config_flow.py new file mode 100644 index 0000000..1d3293e --- /dev/null +++ b/custom_components/smart_home/config_flow.py @@ -0,0 +1,59 @@ +"""Config flow for Smart Home integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +import aiohttp + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, DEFAULT_PORT, STEP_USER + +_LOGGER = logging.getLogger(__name__) + +class SmartHomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Smart Home.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + # Test connection to API + async with aiohttp.ClientSession() as session: + url = f"http://{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + async with session.get(f"{url}/") as response: + if response.status == 200: + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + else: + errors["base"] = "cannot_connect" + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id=STEP_USER, + data_schema=vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ), + errors=errors, + ) \ No newline at end of file diff --git a/custom_components/smart_home/const.py b/custom_components/smart_home/const.py new file mode 100644 index 0000000..402972a --- /dev/null +++ b/custom_components/smart_home/const.py @@ -0,0 +1,25 @@ +"""Constants for the Smart Home integration.""" +from typing import Final + +DOMAIN: Final = "smart_home" + +CONF_HOST: Final = "host" +CONF_PORT: Final = "port" +CONF_NAME: Final = "name" + +DEFAULT_PORT: Final = 5000 + +# Config flow and options flow +STEP_USER: Final = "user" + +DATA_COORDINATOR: Final = "coordinator" +DATA_CONFIG: Final = "config" + +# Device type mapping +DEVICE_TYPE_MAPPING = { + "switch": "switch", + "pushbutton": "button", + "blind": "cover", + "sensor": "sensor", + "binary_sensor": "binary_sensor" +} \ No newline at end of file diff --git a/custom_components/smart_home/coordinator.py b/custom_components/smart_home/coordinator.py new file mode 100644 index 0000000..095249b --- /dev/null +++ b/custom_components/smart_home/coordinator.py @@ -0,0 +1,133 @@ +"""DataUpdateCoordinator for Smart Home integration.""" +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import timedelta +from typing import Any + +import aiohttp +import websocket +import threading + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +class SmartHomeDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + *, + config: dict[str, Any], + entry_id: str, + ) -> None: + """Initialize.""" + self.config = config + self.api_url = f"http://{config[CONF_HOST]}:{config[CONF_PORT]}" + self.ws_url = f"ws://{config[CONF_HOST]}:{config[CONF_PORT]}/ws" + self.ws = None + self.ws_thread = None + self.entry_id = entry_id + self._devices = {} + self._device_states = {} + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), # Fallback update interval + ) + + self._start_ws_client() + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"{self.api_url}/api/devices") as response: + if response.status != 200: + raise UpdateFailed(f"Error communicating with API: {response.status}") + devices = await response.json() + + # Update internal device cache + new_devices = {} + for device in devices: + if not device["enabled"]: + continue + new_devices[str(device["id"])] = device + self._devices = new_devices + return self._devices + + except aiohttp.ClientError as error: + raise UpdateFailed(f"Error communicating with API: {error}") + + @callback + def async_device_state_update(self, msg: dict[str, Any]) -> None: + """Process device state update from WebSocket.""" + if msg["type"] == "device_update": + device_id = str(msg["device_id"]) + if device_id in self._devices: + self._device_states[device_id] = msg["state"] + self.async_set_updated_data(self._devices) + elif msg["type"] == "initial_states": + for state in msg["states"]: + device_id = str(state["device_id"]) + self._device_states[device_id] = state["state"] + self.async_set_updated_data(self._devices) + + def _ws_connect(self) -> None: + """Connect to WebSocket in a separate thread.""" + _LOGGER.info("Connecting to WebSocket") + websocket.enableTrace(True) + self.ws = websocket.WebSocketApp( + self.ws_url, + on_message=self._ws_message, + on_error=self._ws_error, + on_close=self._ws_close, + ) + self.ws.run_forever() + + def _ws_message(self, _, message: str) -> None: + """Handle incoming WebSocket message.""" + try: + msg = json.loads(message) + self.hass.add_job(self.async_device_state_update, msg) + except json.JSONDecodeError: + _LOGGER.error("Failed to parse WebSocket message") + + def _ws_error(self, _, error: Any) -> None: + """Handle WebSocket error.""" + _LOGGER.error("WebSocket error: %s", error) + + def _ws_close(self, *args: Any) -> None: + """Handle WebSocket close.""" + _LOGGER.warning("WebSocket connection closed") + # Attempt to reconnect after a delay + self.hass.loop.call_later(30, self._start_ws_client) + + def _start_ws_client(self) -> None: + """Start WebSocket client in a separate thread.""" + if self.ws_thread is not None and self.ws_thread.is_alive(): + _LOGGER.warning("WebSocket client already running") + return + + _LOGGER.info("Starting WebSocket client") + self.ws_thread = threading.Thread(target=self._ws_connect, daemon=True) + self.ws_thread.start() + + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + if self.ws: + self.ws.close() diff --git a/custom_components/smart_home/cover.py b/custom_components/smart_home/cover.py new file mode 100644 index 0000000..cbfd182 --- /dev/null +++ b/custom_components/smart_home/cover.py @@ -0,0 +1,137 @@ +"""Support for Smart Home blinds.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +from homeassistant.components.cover import ( + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, DATA_COORDINATOR +from .coordinator import SmartHomeDataUpdateCoordinator +from .entity import SmartHomeEntity + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Smart Home covers.""" + coordinator: SmartHomeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + + entities = [] + for device_id, device in coordinator._devices.items(): + if device["device_type"] == "blind": + entities.append(SmartHomeCover(coordinator, device_id)) + + async_add_entities(entities) + +class SmartHomeCover(SmartHomeEntity, CoverEntity): + """Representation of a Smart Home cover.""" + + def __init__( + self, + coordinator: SmartHomeDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize the cover.""" + super().__init__(coordinator, device_id) + + # Set supported features based on position control capability + if self.device_data.get("can_use_positions", False): + self._attr_supported_features = CoverEntityFeature.SET_POSITION + else: + self._attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + if self.device_data.get("can_use_positions", False): + await self._async_set_cover_position(100) + else: + async with aiohttp.ClientSession() as session: + url = f"{self.coordinator.api_url}/api/devices/{self._device_id}/state" + async with session.put(url, json={"position": 100}) as response: + if response.status != 200: + _LOGGER.error("Failed to open cover: %s", response.status) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + if self.device_data.get("can_use_positions", False): + await self._async_set_cover_position(0) + else: + async with aiohttp.ClientSession() as session: + url = f"{self.coordinator.api_url}/api/devices/{self._device_id}/state" + async with session.put(url, json={"position": 0}) as response: + if response.status != 200: + _LOGGER.error("Failed to close cover: %s", response.status) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + async with aiohttp.ClientSession() as session: + url = f"{self.coordinator.api_url}/api/devices/{self._device_id}/state" + async with session.put(url, json={"position": -1}) as response: + if response.status != 200: + _LOGGER.error("Failed to stop cover: %s", response.status) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + if not self.device_data.get("can_use_positions", False): + _LOGGER.warning("Trying to set position for a blind that doesn't support position control") + return + + position = kwargs.get("position", 0) + await self._async_set_cover_position(position) + + async def _async_set_cover_position(self, position: int) -> None: + """Helper to set cover position.""" + async with aiohttp.ClientSession() as session: + url = f"{self.coordinator.api_url}/api/devices/{self._device_id}/state" + async with session.put(url, json={"position": position}) as response: + if response.status != 200: + _LOGGER.error("Failed to set cover position: %s", response.status) + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover.""" + position = self.device_state.get("position") + if position is None or position == -1: + return None + return position + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + position = self.current_cover_position + if position is None: + return None + return position == 0 + + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + if self.device_data.get("moving", False): + if self.device_data.get("can_use_positions", False): + return self.device_state.get("position", 0) > self.device_data.get("current_position", 0) + else: + # For non-position blinds, check if moving up + return self.device_state.get("position", 0) == 100 + return False + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + if self.device_data.get("moving", False): + if self.device_data.get("can_use_positions", False): + return self.device_state.get("position", 0) < self.device_data.get("current_position", 0) + else: + # For non-position blinds, check if moving down + return self.device_state.get("position", 0) == 0 + return False \ No newline at end of file diff --git a/custom_components/smart_home/entity.py b/custom_components/smart_home/entity.py new file mode 100644 index 0000000..847da4c --- /dev/null +++ b/custom_components/smart_home/entity.py @@ -0,0 +1,63 @@ +"""Base entity for Smart Home integration.""" +from __future__ import annotations + +import logging + +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SmartHomeDataUpdateCoordinator + + +_LOGGER = logging.getLogger(__name__) + + +class SmartHomeEntity(CoordinatorEntity): + """Base class for Smart Home entities.""" + + def __init__( + self, + coordinator: SmartHomeDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device_id = device_id + self._attr_device_info = { + "identifiers": {(DOMAIN, device_id)}, + "name": self.device_data["name"], + "manufacturer": "casaIT", + "model": self.device_data["device_type"], + "via_device": (DOMAIN, coordinator.entry_id), + } + + @property + def device_data(self) -> dict: + """Get device data.""" + return self.coordinator._devices[self._device_id] + + @property + def device_state(self) -> dict: + """Get device state.""" + return self.coordinator._device_states.get(self._device_id, {}) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self._device_id in self.coordinator._devices + + @property + def name(self): + """Name of the sensor""" + return self.device_data["name"] + + @property + def unique_id(self): + """ID of the sensor""" + return f'casait_{self.device_data["id"]}_{self.device_data["uuid"]}' + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/custom_components/smart_home/manifest.json b/custom_components/smart_home/manifest.json new file mode 100644 index 0000000..a7bf1ba --- /dev/null +++ b/custom_components/smart_home/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "smart_home", + "name": "casaIT SmartHome", + "documentation": "https://github.com/yourusername/smart_home", + "dependencies": [], + "codeowners": ["@Gurkengewuerz"], + "requirements": ["websocket-client>=1.8.0"], + "config_flow": true, + "iot_class": "local_push", + "version": "1.0.0" +} diff --git a/custom_components/smart_home/sensor.py b/custom_components/smart_home/sensor.py new file mode 100644 index 0000000..5e1b125 --- /dev/null +++ b/custom_components/smart_home/sensor.py @@ -0,0 +1,96 @@ +"""Support for Smart Home sensors.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.sensor import ( + SensorEntity, + SensorStateClass, + SensorDeviceClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, DATA_COORDINATOR +from .coordinator import SmartHomeDataUpdateCoordinator +from .entity import SmartHomeEntity + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Smart Home sensors.""" + coordinator: SmartHomeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + + entities = [] + for device_id, device in coordinator._devices.items(): + if device["device_type"] == "sensor": + if device.get("onewire_conversion_type") == "DS2438TEMP": + entities.append(SmartHomeTemperatureSensor(coordinator, device_id)) + elif device.get("onewire_type") == "DS18XB20": + entities.append(SmartHomeTemperatureSensor(coordinator, device_id)) + elif device.get("onewire_conversion_type") in ["HIH4030", "HIH5030"]: + entities.append(SmartHomeHumiditySensor(coordinator, device_id)) + elif device.get("onewire_conversion_type") == "TEPT5600": + entities.append(SmartHomeLightSensor(coordinator, device_id)) + else: + entities.append(SmartHomeGenericSensor(coordinator, device_id)) + + async_add_entities(entities) + +class SmartHomeTemperatureSensor(SmartHomeEntity, SensorEntity): + """Representation of a Smart Home temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = "°C" + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + return self.device_state.get("value") + +class SmartHomeHumiditySensor(SmartHomeEntity, SensorEntity): + """Representation of a Smart Home humidity sensor.""" + + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = "%" + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + return self.device_state.get("value") + +class SmartHomeLightSensor(SmartHomeEntity, SensorEntity): + """Representation of a Smart Home light sensor.""" + + _attr_device_class = SensorDeviceClass.ILLUMINANCE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = "lx" + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + _LOGGER.debug(f"Light sensor value: {self.device_state}") + return self.device_state.get("value") + +class SmartHomeGenericSensor(SmartHomeEntity, SensorEntity): + """Representation of a Smart Home generic sensor.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + return self.device_state.get("value") + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + return self.device_state.get("unit") diff --git a/custom_components/smart_home/switch.py b/custom_components/smart_home/switch.py new file mode 100644 index 0000000..f108c5a --- /dev/null +++ b/custom_components/smart_home/switch.py @@ -0,0 +1,56 @@ +"""Support for Smart Home switches.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, DATA_COORDINATOR +from .coordinator import SmartHomeDataUpdateCoordinator +from .entity import SmartHomeEntity + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Smart Home switches.""" + coordinator: SmartHomeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + + entities = [] + for device_id, device in coordinator._devices.items(): + if device["device_type"] == "switch" or device["device_type"] == "pushbutton": + entities.append(SmartHomeSwitch(coordinator, device_id)) + + async_add_entities(entities) + +class SmartHomeSwitch(SmartHomeEntity, SwitchEntity): + """Representation of a Smart Home switch.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + async with aiohttp.ClientSession() as session: + url = f"{self.coordinator.api_url}/api/devices/{self._device_id}/state" + async with session.put(url, json={"state": True}) as response: + if response.status != 200: + _LOGGER.error("Failed to turn on switch: %s", response.status) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + async with aiohttp.ClientSession() as session: + url = f"{self.coordinator.api_url}/api/devices/{self._device_id}/state" + async with session.put(url, json={"state": False}) as response: + if response.status != 200: + _LOGGER.error("Failed to turn off switch: %s", response.status) + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.device_state.get("state", False) diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..a3dae92 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "casaIT SmartHome", + "render_readme": true, + "homeassistant": "2024.9.0" +} \ No newline at end of file