DebenOldert преди 7 месеца
родител
ревизия
0c614d9086

+ 31 - 0
custom_components/odido_klikklaar/README.md

@@ -0,0 +1,31 @@
+# Integration 101 Template
+
+So this is your starting point into writing your first Home Assistant integration (or maybe just advancing your knowledge and improving something you have already written).
+
+Well, firstly, I hope you enjoy doing it.  There is something very satisfying to be able to build something into Home Assistant that controls your devices!
+
+So, below is a more detailed explanaition of the major building blocks demonstrated in this example.
+
+If you get stuck, either post a forum question or an issue on this github repo and I'll try my best to help you.  As a note, it always helps if I can see your code, so please make sure you provide a link to that.
+
+1. **Config Flow**
+
+    This is the functionality to provide setup via the UI.  Many new starters to coding, start with a yaml config as it seems easier, but once you understand how to write a config flow (and it is quite simple), this is a much better way to setup and manage your integration from the start.
+
+    See the config_flow.py file with comments to see how it works.  This is much enhanced from the scaffold version to include a reconfigure flow and options flow.
+
+    It is possible (and quite simple) to do multi step flows, which will be covered in another later example.
+
+2. **The DataUpdateCoordinator**
+
+    To me, this should be a default for any integration that gets its data from an api (whether it be a pull (polling) or push type api). It provides much of the functionality to manage polling, receive a websocket message, process your data and update all your entities without you having to do much coding and ensures that all api code is ring fenced within this class.
+
+3. **Devices**
+
+    These are a nice way to group your entities that relate to the same physical device.  Again, this is often very confusing how to create these for an integration.  However, with simple explained code, this can be quite straight forward.
+
+4. **Platform Entities**
+
+    These are your sensors, switches, lights etc, and this example covers the 2 most simple ones of binary sensors, things that only have 2 states, ie On/Off or Open/Closed or Hot/Cold etc and sensors, things that can have many states ie temperature, power, luminance etc.
+
+    There are within Home Assistant things called device classes that describe what your sensor is and set icons, units etc for it.

+ 91 - 0
custom_components/odido_klikklaar/__init__.py

@@ -0,0 +1,91 @@
+"""The Integration 101 Template integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.device_registry import DeviceEntry
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .coordinator import RouterCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR,
+                             Platform.SENSOR,
+                             Platform.BUTTON,
+                             Platform.SWITCH,
+                             Platform.TEXT]
+
+type RouterConfigEntry = ConfigEntry[RuntimeData]
+
+
+@dataclass
+class RuntimeData:
+    """Class to hold your data."""
+
+    coordinator: DataUpdateCoordinator
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: RouterConfigEntry) -> bool:
+    """Set up Example Integration from a config entry."""
+
+    # Initialise the coordinator that manages data updates from your api.
+    # This is defined in coordinator.py
+    coordinator = RouterCoordinator(hass, config_entry)
+
+    # Perform an initial data load from api.
+    # async_config_entry_first_refresh() is special in that it does not log errors if it fails
+    if not await coordinator.api.async_login():
+        raise ConfigEntryNotReady
+
+    # Initialise a listener for config flow options changes.
+    # This will be removed automatically if the integration is unloaded.
+    # See config_flow for defining an options setting that shows up as configure
+    # on the integration.
+    # If you do not want any config flow options, no need to have listener.
+    config_entry.async_on_unload(
+        config_entry.add_update_listener(_async_update_listener)
+    )
+
+    # Add the coordinator and update listener to config runtime data to make
+    # accessible throughout your integration
+    config_entry.runtime_data = RuntimeData(coordinator)
+
+    # Setup platforms (based on the list of entity types in PLATFORMS defined above)
+    # This calls the async_setup method in each of your entity type files.
+    await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
+    # Return true to denote a successful setup.
+    return True
+
+
+async def _async_update_listener(hass: HomeAssistant, config_entry: RouterConfigEntry):
+    """Handle config options update."""
+    # Reload the integration when the options change.
+    await hass.config_entries.async_reload(config_entry.entry_id)
+
+
+async def async_remove_config_entry_device(
+    hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
+) -> bool:
+    """Delete device if selected from UI."""
+    # Adding this function shows the delete device option in the UI.
+    # Remove this function if you do not want that option.
+    # You may need to do some checks here before allowing devices to be removed.
+    return True
+
+
+async def async_unload_entry(hass: HomeAssistant, config_entry: RouterConfigEntry) -> bool:
+    """Unload a config entry."""
+    # This is called when you remove your integration or shutdown HA.
+    # If you have created any custom services, they need to be removed here too.
+
+    # Unload platforms and return result
+    return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)

+ 113 - 0
custom_components/odido_klikklaar/api.py

@@ -0,0 +1,113 @@
+"""API Placeholder.
+
+You should create your api seperately and have it hosted on PYPI.  This is included here for the sole purpose
+of making this example code executable.
+"""
+
+import logging
+import base64
+import aiohttp
+import asyncio
+
+from .const import (API_SCHEMA,
+                    API_LOGIN_PATH,
+                    API_BASE_PATH,
+                    API_TIMEOUT,
+                    LOGIN_PAYLOAD,
+                    KEY_RESULT,
+                    KEY_OBJECT,
+                    VAL_SUCCES)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class RouterAPI:
+    """Class for example API."""
+
+    def __init__(self,
+                 host: str,
+                 user: str,
+                 pwd: str,
+                 session: aiohttp) -> None:
+        """Initialise."""
+        self.host = host
+        self.user = user
+        self.pwd = pwd
+        self.session: aiohttp = session
+    
+    async def async_login(self) -> bool:
+        """Login and obtain the session cookie"""
+
+        payload = LOGIN_PAYLOAD.copy()
+        payload['Input_Account'] = self.user
+        payload['Input_Passwd'] = base64.b64encode(
+            self.password.encode('utf-8')).decode('utf-8')
+
+        response = await self._session.post(
+            f'{API_SCHEMA}://{self.ip}{API_LOGIN_PATH}',
+            json=payload)
+        
+        if response.ok:
+            try:
+                data = response.json()
+
+                if 'result' in data:
+                    if data['result'] == VAL_SUCCES:
+                        return True
+                    else:
+                        raise RouterAPIAuthError('Login failed')
+                else:
+                    raise RouterAPIInvalidResponse('Key "result" not set in response')
+                    
+            except Exception as json_exception:
+                raise RouterAPIInvalidResponse(f'Unable to decode login response') \
+                    from json_exception
+            
+        raise RouterAPIConnectionError(
+            f'Error connecting to router. Status: {response.status}')
+    
+    async def async_query_api(self,
+                              oid: str) -> dict:
+        """Query an authenticated API endpoint"""
+        try:
+            async with asyncio.timeout(API_TIMEOUT):
+                response = await self._session.get(
+                    f'{API_SCHEMA}://{self.endpoint}{API_BASE_PATH}',
+                    params={'oid': oid})
+
+                if response.ok:
+                    try:
+                        data: dict = await response.json()
+
+                        if data.get(KEY_RESULT, None) == VAL_SUCCES:
+                            return data.get(KEY_OBJECT, [{}])[0]
+                        else:
+                            raise RouterAPIInvalidResponse(f'Response returned error')
+
+                    except Exception as json_exception:
+                        raise RouterAPIInvalidResponse(f'Unable to decode JSON') \
+                            from json_exception
+                else:
+                    raise RouterAPIConnectionError(
+                        f'Error retrieving API. Status: {response.status}')
+
+        except Exception as exception:
+            raise RouterAPIConnectionError('Unable to connect to router API') \
+                from exception
+
+
+    @property
+    def controller_name(self) -> str:
+        """Return the name of the controller."""
+        return self.host.replace(".", "_")
+
+
+class RouterAPIAuthError(Exception):
+    """Exception class for auth error."""
+
+
+class RouterAPIConnectionError(Exception):
+    """Exception class for connection error."""
+
+class RouterAPIInvalidResponse(Exception):
+    """Exception class for invalid API response."""

+ 112 - 0
custom_components/odido_klikklaar/binary_sensor.py

@@ -0,0 +1,112 @@
+"""Interfaces with the Integration 101 Template api sensors."""
+
+import logging
+
+from homeassistant.components.binary_sensor import (
+    BinarySensorDeviceClass,
+    BinarySensorEntity,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import MyConfigEntry
+from .api import Device, DeviceType
+from .const import DOMAIN
+from .coordinator import ExampleCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    config_entry: MyConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+):
+    """Set up the Binary Sensors."""
+    # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py
+    coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator
+
+    # Enumerate all the binary sensors in your data value from your DataUpdateCoordinator and add an instance of your binary sensor class
+    # to a list for each one.
+    # This maybe different in your specific case, depending on how your data is structured
+    binary_sensors = [
+        ExampleBinarySensor(coordinator, device)
+        for device in coordinator.data.devices
+        if device.device_type == DeviceType.DOOR_SENSOR
+    ]
+
+    # Create the binary sensors.
+    async_add_entities(binary_sensors)
+
+
+class ExampleBinarySensor(CoordinatorEntity, BinarySensorEntity):
+    """Implementation of a sensor."""
+
+    def __init__(self, coordinator: ExampleCoordinator, device: Device) -> None:
+        """Initialise sensor."""
+        super().__init__(coordinator)
+        self.device = device
+        self.device_id = device.device_id
+
+    @callback
+    def _handle_coordinator_update(self) -> None:
+        """Update sensor with latest data from coordinator."""
+        # This method is called by your DataUpdateCoordinator when a successful update runs.
+        self.device = self.coordinator.get_device_by_id(
+            self.device.device_type, self.device_id
+        )
+        _LOGGER.debug("Device: %s", self.device)
+        self.async_write_ha_state()
+
+    @property
+    def device_class(self) -> str:
+        """Return device class."""
+        # https://developers.home-assistant.io/docs/core/entity/binary-sensor#available-device-classes
+        return BinarySensorDeviceClass.DOOR
+
+    @property
+    def device_info(self) -> DeviceInfo:
+        """Return device information."""
+        # Identifiers are what group entities into the same device.
+        # If your device is created elsewhere, you can just specify the indentifiers parameter.
+        # If your device connects via another device, add via_device parameter with the indentifiers of that device.
+        return DeviceInfo(
+            name=f"ExampleDevice{self.device.device_id}",
+            manufacturer="ACME Manufacturer",
+            model="Door&Temp v1",
+            sw_version="1.0",
+            identifiers={
+                (
+                    DOMAIN,
+                    f"{self.coordinator.data.controller_name}-{self.device.device_id}",
+                )
+            },
+        )
+
+    @property
+    def name(self) -> str:
+        """Return the name of the sensor."""
+        return self.device.name
+
+    @property
+    def is_on(self) -> bool | None:
+        """Return if the binary sensor is on."""
+        # This needs to enumerate to true or false
+        return self.device.state
+
+    @property
+    def unique_id(self) -> str:
+        """Return unique id."""
+        # All entities must have a unique id.  Think carefully what you want this to be as
+        # changing it later will cause HA to create new entities.
+        return f"{DOMAIN}-{self.device.device_unique_id}"
+
+    @property
+    def extra_state_attributes(self):
+        """Return the extra state attributes."""
+        # Add any additional attributes you want on your sensor.
+        attrs = {}
+        attrs["extra_info"] = "Extra Info"
+        return attrs

+ 202 - 0
custom_components/odido_klikklaar/config_flow.py

@@ -0,0 +1,202 @@
+"""Config flow for Integration 101 Template integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+    ConfigEntry,
+    ConfigFlow,
+    ConfigFlowResult,
+    OptionsFlow,
+)
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_PASSWORD,
+    CONF_SCAN_INTERVAL,
+    CONF_USERNAME,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .api import (RouterAPI,
+                  RouterAPIAuthError,
+                  RouterAPIConnectionError,
+                  RouterAPIInvalidResponse)
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+# TODO adjust the data schema to the data that you need
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST, description={"suggested_value": "192.168.1.1"}): str,
+        vol.Required(CONF_USERNAME, description={"suggested_value": "admin"}): str,
+        vol.Required(CONF_PASSWORD, description={"suggested_value": "*****"}): str,
+    }
+)
+
+
+async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
+    """Validate the user input allows us to connect.
+
+    Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
+    """
+    # TODO validate the data can be used to set up a connection.
+
+    # If your PyPI package is not built with async, pass your methods
+    # to the executor:
+    # await hass.async_add_executor_job(
+    #     your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD]
+    # )
+
+    session = async_get_clientsession(
+        hass=hass,
+        verify_ssl=False
+    )
+
+    api = RouterAPI(host=data[CONF_HOST],
+                    user=data[CONF_USERNAME],
+                    pwd=data[CONF_PASSWORD],
+                    session=session)
+    try:
+        await api.async_login()
+    except RouterAPIAuthError as err:
+        raise InvalidAuth from err
+    except RouterAPIConnectionError as err:
+        raise CannotConnect from err
+    
+    return {"title": f"Odido Klik & Klaar router - {data[CONF_HOST]}"}
+
+
+class RouterConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Example Integration."""
+
+    VERSION = 1
+    _input_data: dict[str, Any]
+
+    @staticmethod
+    @callback
+    def async_get_options_flow(config_entry):
+        """Get the options flow for this handler."""
+        # Remove this method and the ExampleOptionsFlowHandler class
+        # if you do not want any options for your integration.
+        return RouterOptionsFlowHandler(config_entry)
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        # Called when you initiate adding an integration via the UI
+        errors: dict[str, str] = {}
+
+        if user_input is not None:
+            # The form has been filled in and submitted, so process the data provided.
+            try:
+                # Validate that the setup data is valid and if not handle errors.
+                # The errors["base"] values match the values in your strings.json and translation files.
+                info = await validate_input(self.hass, user_input)
+            except CannotConnect:
+                errors["base"] = "cannot_connect"
+            except InvalidAuth:
+                errors["base"] = "invalid_auth"
+            except Exception:  # pylint: disable=broad-except
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+
+            if "base" not in errors:
+                # Validation was successful, so create a unique id for this instance of your integration
+                # and create the config entry.
+                await self.async_set_unique_id(info.get("title"))
+                self._abort_if_unique_id_configured()
+                return self.async_create_entry(title=info["title"], data=user_input)
+
+        # Show initial form.
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reconfigure(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Add reconfigure step to allow to reconfigure a config entry."""
+        # This methid displays a reconfigure option in the integration and is
+        # different to options.
+        # It can be used to reconfigure any of the data submitted when first installed.
+        # This is optional and can be removed if you do not want to allow reconfiguration.
+        errors: dict[str, str] = {}
+        config_entry = self.hass.config_entries.async_get_entry(
+            self.context["entry_id"]
+        )
+
+        if user_input is not None:
+            try:
+                user_input[CONF_HOST] = config_entry.data[CONF_HOST]
+                await validate_input(self.hass, user_input)
+            except CannotConnect:
+                errors["base"] = "cannot_connect"
+            except InvalidAuth:
+                errors["base"] = "invalid_auth"
+            except Exception:  # pylint: disable=broad-except
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    config_entry,
+                    unique_id=config_entry.unique_id,
+                    data={**config_entry.data, **user_input},
+                    reason="reconfigure_successful",
+                )
+        return self.async_show_form(
+            step_id="reconfigure",
+            data_schema=vol.Schema(
+                {
+                    vol.Required(
+                        CONF_USERNAME, default=config_entry.data[CONF_USERNAME]
+                    ): str,
+                    vol.Required(CONF_PASSWORD): str,
+                }
+            ),
+            errors=errors,
+        )
+
+
+class RouterOptionsFlowHandler(OptionsFlow):
+    """Handles the options flow."""
+
+    def __init__(self, config_entry: ConfigEntry) -> None:
+        """Initialize options flow."""
+        self.config_entry = config_entry
+        self.options = dict(config_entry.options)
+
+    async def async_step_init(self, user_input=None):
+        """Handle options flow."""
+        if user_input is not None:
+            options = self.config_entry.options | user_input
+            return self.async_create_entry(title="", data=options)
+
+        # It is recommended to prepopulate options fields with default values if available.
+        # These will be the same default values you use on your coordinator for setting variable values
+        # if the option has not been set.
+        data_schema = vol.Schema(
+            {
+                vol.Required(
+                    CONF_SCAN_INTERVAL,
+                    default=self.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL),
+                ): (vol.All(vol.Coerce(int), vol.Clamp(min=MIN_SCAN_INTERVAL))),
+            }
+        )
+
+        return self.async_show_form(step_id="init", data_schema=data_schema)
+
+
+class CannotConnect(HomeAssistantError):
+    """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+    """Error to indicate there is invalid auth."""

+ 42 - 0
custom_components/odido_klikklaar/const.py

@@ -0,0 +1,42 @@
+"""Constants for Odido Klik&Klaar 5G router"""
+
+from typing import Final
+
+# API
+API_SCHEMA: Final[str] = 'https'
+API_BASE_PATH: Final[str] = '/cgi-bin/DAL'
+API_LOGIN_PATH: Final[str] = '/UserLogin'
+API_TIMEOUT: Final = 10
+API_TIMEZONE: Final = "Europe/Amsterdam"
+
+# Endpoints
+EP_CELLINFO: Final[str] = 'status'
+EP_LANINFO: Final[str] = 'lanhosts'
+EP_DEVICESTATUS: Final[str] = 'cardpage_status'
+
+# Keys & values
+KEY_RESULT: Final[str] = 'result'
+KEY_OBJECT: Final[str] = 'Object'
+VAL_SUCCES: Final[str] = 'ZCFG_SUCCESS'
+
+# Base component constants.
+DOMAIN: Final = "odido"
+NAME: Final = "ZYXEL"
+SUPPLIER: Final = "Odido"
+VERSION: Final = "0.0.1"
+
+# Defaults
+DEFAULT_IP: Final[str] = '192.168.1.1'
+DEFAULT_USER: Final[str] = 'admin'
+DEFAULT_NAME: Final[str] = NAME
+DEFAULT_SCAN_INTERVAL: Final = 60
+MIN_SCAN_INTERVAL = 30
+
+# Payloads
+LOGIN_PAYLOAD: dict = {
+    'Input_Account': None,
+    'Input_Passwd': None,
+    'currLang': 'en',
+    'RememberPassword': 0,
+    'SHA512_password': False
+}

+ 107 - 0
custom_components/odido_klikklaar/coordinator.py

@@ -0,0 +1,107 @@
+"""Integration 101 Template integration using DataUpdateCoordinator."""
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+import asyncio
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_PASSWORD,
+    CONF_SCAN_INTERVAL,
+    CONF_USERNAME,
+)
+from homeassistant.core import DOMAIN, HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .api import RouterAPI, APIAuthError, Device, DeviceType
+from .const import (DEFAULT_SCAN_INTERVAL,
+                    EP_CELLINFO,
+                    EP_DEVICESTATUS,
+                    EP_LANINFO)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class RouterAPIData:
+    """Class to hold api data."""
+
+    data: dict
+
+
+class RouterCoordinator(DataUpdateCoordinator):
+    """My example coordinator."""
+
+    data: RouterAPIData
+
+    def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+        """Initialize coordinator."""
+
+        # Set variables from values entered in config flow setup
+        self.host = config_entry.data[CONF_HOST]
+        self.user = config_entry.data[CONF_USERNAME]
+        self.pwd = config_entry.data[CONF_PASSWORD]
+
+        # set variables from options.  You need a default here incase options have not been set
+        self.poll_interval = config_entry.options.get(
+            CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
+        )
+
+        # Initialise DataUpdateCoordinator
+        super().__init__(
+            hass,
+            _LOGGER,
+            name=f"{DOMAIN} ({config_entry.unique_id})",
+            # Method to call on every update interval.
+            update_method=self.async_update_data,
+            # Polling interval. Will only be polled if there are subscribers.
+            # Using config option here but you can just use a value.
+            update_interval=timedelta(seconds=self.poll_interval),
+        )
+
+        session = async_get_clientsession(
+            hass=hass,
+            verify_ssl=False
+        )
+
+        # Initialise your api here
+        self.api = RouterAPI(host=self.host,
+                             user=self.user,
+                             pwd=self.pwd,
+                             session=session)
+
+    async def async_update_data(self):
+        """Fetch data from API endpoint.
+
+        This is the place to pre-process the data to lookup tables
+        so entities can quickly look up their data.
+        """
+        try:
+            # First login to refresh session
+            if await self.api.async_login():
+                # Get API endpoints
+                endpoints = [EP_CELLINFO,
+                             EP_DEVICESTATUS,
+                             EP_LANINFO]
+                
+                results = await asyncio.gather(
+                    *[self.api.async_query_api(oid=endpoint) for endpoint in endpoints],
+                    return_exceptions=True)
+                
+                return RouterAPIData({
+                    endpoints[i]: results[i]
+                    for i in len(endpoints)
+                })
+
+        except APIAuthError as err:
+            _LOGGER.error(err)
+            raise UpdateFailed(err) from err
+        except Exception as err:
+            # This will show entities as unavailable by raising UpdateFailed exception
+            raise UpdateFailed(f"Error communicating with API: {err}") from err
+
+        # # What is returned here is stored in self.data by the DataUpdateCoordinator
+        # return RouterAPIData(self.api.controller_name, devices)

+ 17 - 0
custom_components/odido_klikklaar/manifest.json

@@ -0,0 +1,17 @@
+{
+  "domain": "odido",
+  "name": "Odido Klik & Klaar 5G router",
+  "codeowners": [
+    "@DebenOldert"
+  ],
+  "config_flow": true,
+  "dependencies": [],
+  "documentation": "https://github.com/DebenOldert/odido_5g_router",
+  "homekit": {},
+  "iot_class": "local_polling",
+  "requirements": [],
+  "single_config_entry": false,
+  "ssdp": [],
+  "version": "0.0.1",
+  "zeroconf": []
+}

+ 126 - 0
custom_components/odido_klikklaar/sensor.py

@@ -0,0 +1,126 @@
+"""Interfaces with the Integration 101 Template api sensors."""
+
+import logging
+
+from homeassistant.components.sensor import (
+    SensorDeviceClass,
+    SensorEntity,
+    SensorStateClass,
+)
+from homeassistant.const import UnitOfTemperature
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import MyConfigEntry
+from .api import Device, DeviceType
+from .const import DOMAIN
+from .coordinator import ExampleCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    config_entry: MyConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+):
+    """Set up the Sensors."""
+    # This gets the data update coordinator from the config entry runtime data as specified in your __init__.py
+    coordinator: ExampleCoordinator = config_entry.runtime_data.coordinator
+
+    # Enumerate all the sensors in your data value from your DataUpdateCoordinator and add an instance of your sensor class
+    # to a list for each one.
+    # This maybe different in your specific case, depending on how your data is structured
+    sensors = [
+        ExampleSensor(coordinator, device)
+        for device in coordinator.data.devices
+        if device.device_type == DeviceType.TEMP_SENSOR
+    ]
+
+    # Create the sensors.
+    async_add_entities(sensors)
+
+
+class ExampleSensor(CoordinatorEntity, SensorEntity):
+    """Implementation of a sensor."""
+
+    def __init__(self, coordinator: ExampleCoordinator, device: Device) -> None:
+        """Initialise sensor."""
+        super().__init__(coordinator)
+        self.device = device
+        self.device_id = device.device_id
+
+    @callback
+    def _handle_coordinator_update(self) -> None:
+        """Update sensor with latest data from coordinator."""
+        # This method is called by your DataUpdateCoordinator when a successful update runs.
+        self.device = self.coordinator.get_device_by_id(
+            self.device.device_type, self.device_id
+        )
+        _LOGGER.debug("Device: %s", self.device)
+        self.async_write_ha_state()
+
+    @property
+    def device_class(self) -> str:
+        """Return device class."""
+        # https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes
+        return SensorDeviceClass.TEMPERATURE
+
+    @property
+    def device_info(self) -> DeviceInfo:
+        """Return device information."""
+        # Identifiers are what group entities into the same device.
+        # If your device is created elsewhere, you can just specify the indentifiers parameter.
+        # If your device connects via another device, add via_device parameter with the indentifiers of that device.
+        return DeviceInfo(
+            name=f"ExampleDevice{self.device.device_id}",
+            manufacturer="ACME Manufacturer",
+            model="Door&Temp v1",
+            sw_version="1.0",
+            identifiers={
+                (
+                    DOMAIN,
+                    f"{self.coordinator.data.controller_name}-{self.device.device_id}",
+                )
+            },
+        )
+
+    @property
+    def name(self) -> str:
+        """Return the name of the sensor."""
+        return self.device.name
+
+    @property
+    def native_value(self) -> int | float:
+        """Return the state of the entity."""
+        # Using native value and native unit of measurement, allows you to change units
+        # in Lovelace and HA will automatically calculate the correct value.
+        return float(self.device.state)
+
+    @property
+    def native_unit_of_measurement(self) -> str | None:
+        """Return unit of temperature."""
+        return UnitOfTemperature.CELSIUS
+
+    @property
+    def state_class(self) -> str | None:
+        """Return state class."""
+        # https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes
+        return SensorStateClass.MEASUREMENT
+
+    @property
+    def unique_id(self) -> str:
+        """Return unique id."""
+        # All entities must have a unique id.  Think carefully what you want this to be as
+        # changing it later will cause HA to create new entities.
+        return f"{DOMAIN}-{self.device.device_unique_id}"
+
+    @property
+    def extra_state_attributes(self):
+        """Return the extra state attributes."""
+        # Add any additional attributes you want on your sensor.
+        attrs = {}
+        attrs["extra_info"] = "Extra Info"
+        return attrs

+ 41 - 0
custom_components/odido_klikklaar/strings.json

@@ -0,0 +1,41 @@
+{
+  "config": {
+    "title": "Integration 101 Template Integration",
+    "abort": {
+      "already_configured": "Device is already configured",
+      "reconfigure_successful": "Reconfiguration successful"
+    },
+    "error": {
+      "cannot_connect": "Failed to connect",
+      "invalid_auth": "Invalid authentication",
+      "unknown": "Unexpected error"
+    },
+    "step": {
+      "user": {
+        "data": {
+          "host": "Host",
+          "password": "Password",
+          "username": "Username"
+        }
+      },
+      "reconfigure": {
+        "data": {
+          "host": "Host",
+          "password": "Password",
+          "username": "Username"
+        }
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "init": {
+        "data": {
+          "scan_interval": "Scan Interval (seconds)"
+        },
+        "description": "Amend your options.",
+        "title": "Example Integration Options"
+      }
+    }
+  }
+}

+ 41 - 0
custom_components/odido_klikklaar/translations/en.json

@@ -0,0 +1,41 @@
+{
+  "config": {
+    "title": "Integration 101 Template Integration",
+    "abort": {
+      "already_configured": "Device is already configured",
+      "reconfigure_successful": "Reconfiguration successful"
+    },
+    "error": {
+      "cannot_connect": "Failed to connect",
+      "invalid_auth": "Invalid authentication",
+      "unknown": "Unexpected error"
+    },
+    "step": {
+      "user": {
+        "data": {
+          "host": "Host",
+          "password": "Password",
+          "username": "Username"
+        }
+      },
+      "reconfigure": {
+        "data": {
+          "host": "Host",
+          "password": "Password",
+          "username": "Username"
+        }
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "init": {
+        "data": {
+          "scan_interval": "Scan Interval (seconds)"
+        },
+        "description": "Amend your options.",
+        "title": "Example Integration Options"
+      }
+    }
+  }
+}