diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..e304a9a --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,60 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +Task | Description +-- | -- +Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. +Run Home Assistant configuration against /config | Check the configuration. +Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. +Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. + +### Step by Step debugging + +With the development container, +you can test your custom component in Home Assistant with step by step debugging. + +You need to modify the `configuration.yaml` file in `.devcontainer` folder +by uncommenting the line: + +```yaml +# debugpy: +``` + +Then launch the task `Run Home Assistant on port 9123`, and launch the debugger +with the existing debugging configuration `Python: Attach Local`. + +For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..62fddfa --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,9 @@ +default_config: + +logger: + default: info + logs: + custom_components.ferroamp_portal: debug + +# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..2576207 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +{ + "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "name": "FerroampPortal integration development", + "context": "..", + "appPort": [ + "9123:8123" + ], + "mounts": [ + //"type=bind,source=/etc/localtime,target=/etc/localtime,readonly" + ], + "containerEnv": { + "TZ": "Europe/Stockholm" + }, + "postCreateCommand": "container install", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..507f06e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..70f3d5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,42 @@ +--- +name: Issue +about: Create a report to help us improve + +--- + + + +## Version of the custom_component + + +## Configuration + +```yaml + +Add your logs here. + +``` + +## Describe the bug +A clear and concise description of what the bug is. + + +## Debug log + + + +```text + +Add your logs here. + +``` \ No newline at end of file diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml new file mode 100644 index 0000000..8d51d0f --- /dev/null +++ b/.github/workflows/cron.yaml @@ -0,0 +1,21 @@ +name: Cron actions + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" \ No newline at end of file diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 0000000..552571c --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,55 @@ +name: Pull actions + +on: + pull_request: + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_test.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.saleryd_hrv \ + -o console_output_style=count \ + -p no:sugar \ + tests diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..a5d1154 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,58 @@ +name: Push actions + +on: + push: + branches: + - master + - dev + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + +# tests: +# runs-on: "ubuntu-latest" +# name: Run tests +# steps: +# - name: Check out code from GitHub +# uses: "actions/checkout@v2" +# - name: Setup Python +# uses: "actions/setup-python@v1" +# with: +# python-version: "3.8" +# - name: Install requirements +# run: python3 -m pip install -r requirements_test.txt +# - name: Run tests +# run: | +# pytest \ +# -qq \ +# --timeout=9 \ +# --durations=10 \ +# -n auto \ +# --cov custom_components.saleryd_hrv \ +# -o console_output_style=count \ +# -p no:sugar \ +# tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..492cda3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +pythonenv* +venv +.venv +.coverage +.idea diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2489740 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "justMyCode": false, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "homeassistant.local", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a3d535d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.pythonPath": "/usr/local/bin/python", + "files.associations": { + "*.yaml": "home-assistant" + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d1a0ae7 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration against /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/README.me b/README.me new file mode 100644 index 0000000..cac3bca --- /dev/null +++ b/README.me @@ -0,0 +1,2 @@ +Home assistant integration to provide data missing from ext api by querying Ferroamp Portal API + diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..25e6498 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1,2 @@ +"""Dummy init so that pytest works.""" + diff --git a/custom_components/ferroamp_portal/__init__.py b/custom_components/ferroamp_portal/__init__.py new file mode 100644 index 0000000..5b9f0c6 --- /dev/null +++ b/custom_components/ferroamp_portal/__init__.py @@ -0,0 +1,96 @@ +""" +Custom integration to integrate Saleryd HRV system with Home Assistant. +""" +import asyncio +import logging +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.loader import async_get_integration + +from .api import ApiClient +from .const import ( + CONF_PASSWORD, + CONF_SYSTEM_ID, + CONF_USERNAME, + DOMAIN, + PLATFORMS, + STARTUP_MESSAGE, +) +from .coordinator import FerroampPortalDataUpdateCoordinator + +SCAN_INTERVAL = timedelta(seconds=5) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up this integration using UI.""" + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + + integration = await async_get_integration(hass, DOMAIN) + _LOGGER.info(STARTUP_MESSAGE, integration.version) + + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + system_id = entry.data.get(CONF_SYSTEM_ID) + + session = async_create_clientsession(hass, raise_for_status=True) + client = ApiClient(session, username, password, system_id) + try: + await client.connect() + except (asyncio.TimeoutError, TimeoutError) as ex: + raise ConfigEntryNotReady( + f"Timeout while connecting to Ferroamp Portal" + ) from ex + + coordinator = FerroampPortalDataUpdateCoordinator( + hass, + client, + update_interval=SCAN_INTERVAL, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + # Setup platforms + for platform in PLATFORMS: + if entry.options.get(platform, True): + coordinator.platforms.append(platform) + hass.async_add_job( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + 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.""" + coordinator: FerroampPortalDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator.client.stop() + + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + if platform in coordinator.platforms + ] + ) + ) + if unloaded: + 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/ferroamp_portal/api.py b/custom_components/ferroamp_portal/api.py new file mode 100644 index 0000000..2203a1b --- /dev/null +++ b/custom_components/ferroamp_portal/api.py @@ -0,0 +1,141 @@ +"""Api client""" +import asyncio +import enum +import json +import logging +import re + +import aiohttp + + +class State(enum.Enum): + """Client connection state""" + + NONE = "none" + STOPPED = "stopped" + RUNNING = "running" + RETRYING = "retry" + + +RETRY_TIMER = 15 + +_LOGGER = logging.getLogger(__package__) + + +class ApiClient: + """Portal client""" + + baseurl = "https://portal.ferroamp.com" + auth_endpoint = f"{baseurl}/login" + api_endpoint = f"{baseurl}/graphql/stream" + cookie = None + system_id = None + session = None + + def _get_api_endpoint(self): + return f"{self.api_endpoint}{self.system_id}/" + + async def __aenter__(self, *args, **kwargs): + await self.connect() + return self + + async def __aexit__(self, *args, **kwargs): + pass + + def __init__( + self, + session: aiohttp.ClientSession, + username: str, + password: str, + system_id: int, + ) -> None: + + assert username, "username not provided" + assert password, "password not provided" + assert system_id, "facility ID not provided" + + self.username = username + self.password = password + self.system_id = system_id + self.session = session + self.state = None + self.stream = None + self.headers = {"content-type": "application/json"} + self.data = {} + self.loop = asyncio.get_running_loop() + self.state = State.NONE + + async def _auth(self): + _LOGGER.debug("Authentication to %s", self.auth_endpoint) + data = {"email": self.username, "password": self.password} + await self.session.post(self.auth_endpoint, data=data, allow_redirects=False) + + async def running(self): + """Get data""" + + if self.state == State.RUNNING: + return + + headers = self.headers | {"accept": "text/event-stream"} + data = { + "variables": {"facilityId": f"{self.system_id}"}, + "extensions": {}, + "operationName": "OnEvseMetervalue", + "query": "subscription OnEvseMetervalue($facilityId: FacilityID!) {\n newEvseMeterValue(facilityId: $facilityId) {\n terminalId\n timestamp\n powerActive\n energyActiveMeter\n currentL1\n currentL2\n currentL3\n __typename\n }\n}\n", + } + try: + async with self.session.post( + self.api_endpoint, headers=headers, json=data + ) as response: + self._set_state(State.RUNNING) + _LOGGER.debug("Got response from %s", self.api_endpoint) + async for line in response.content: + if self.state == State.STOPPED: + _LOGGER.debug("Got stop signal") + break + if line: + decoded_line: str = line.decode("utf-8") + _LOGGER.debug("Received [%s]", decoded_line) + if re.match("^data: ", decoded_line): + self.data = json.loads(decoded_line.split(": ", 1)[1])[ + "data" + ]["newEvseMeterValue"] + except (asyncio.TimeoutError, TimeoutError): + _LOGGER.warning("Connection timeout", exc_info=True) + except aiohttp.ClientError: + _LOGGER.error("Connection error", exc_info=True) + except Exception as e: + _LOGGER.error("Unexpected exception", exc_info=True) + raise e + + self.retry() + + async def connect(self): + """Connect to system and wait for connection""" + + async def check_connection(): + while True: + if self.state == State.RUNNING: + break + await asyncio.sleep(0.2) + + await self._auth() + self.start() + await asyncio.wait_for(check_connection(), 10) + + def start(self): + """Start client""" + asyncio.create_task(self.running()) + + def retry(self): + """Retry connection""" + if self.state != State.STOPPED: + self.state = State.RETRYING + self.loop.call_later(RETRY_TIMER, self.start) + + def stop(self): + """Stop client""" + self._set_state(State.STOPPED) + + def _set_state(self, state): + self.state = state diff --git a/custom_components/ferroamp_portal/config_flow.py b/custom_components/ferroamp_portal/config_flow.py new file mode 100644 index 0000000..ac89893 --- /dev/null +++ b/custom_components/ferroamp_portal/config_flow.py @@ -0,0 +1,135 @@ +"""Adds config flow for SalerydLoke.""" +import logging + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .api import ApiClient +from .const import CONF_PASSWORD, CONF_SYSTEM_ID, CONF_USERNAME, DOMAIN, NAME + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class SalerydLokeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for SalerydLoke.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + # Uncomment the next 2 lines if only a single instance of the integration is allowed: + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + valid = await self._test_connection( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_SYSTEM_ID], + ) + if valid: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + else: + self._errors["base"] = "connect" + + return await self._show_config_form(user_input) + + user_input = {} + # Provide defaults for form + user_input[CONF_NAME] = NAME + + return await self._show_config_form(user_input) + + async def _show_config_form(self, user_input): # pylint: disable=unused-argument + """Show the configuration form to edit configuration data.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, + default=user_input[CONF_NAME], + ): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_SYSTEM_ID): int, + } + ), + errors=self._errors, + ) + + async def _test_connection(self, username, password, system_id): + """Return true if connection is working""" + + try: + async with ApiClient( + async_create_clientsession(self.hass, raise_for_status=True), + username, + password, + system_id, + ) as client: + await client.connect() + return True + except Exception as e: # pylint: disable=broad-except + _LOGGER.error("Could not connect", exc_info=True) + pass + + return False + + +# @staticmethod +# @callback +# def async_get_options_flow(config_entry): +# return SalerydLokeOptionsFlowHandler(config_entry) + + +# class SalerydLokeOptionsFlowHandler(config_entries.OptionsFlow): +# """SalerydLoke config flow options handler.""" + +# def __init__(self, config_entry): +# self.config_entry = config_entry + +# async def async_step_init(self, user_input=None): # pylint: disable=unused-argument +# """Manage the options.""" +# return await self.async_step_user() + +# async def async_step_user(self, user_input=None): +# """Handle a flow initialized by the user.""" +# errors: Dict[str, str] = {} + +# if user_input is not None: +# if not errors: +# return self.async_create_entry(title="", data=user_input) + +# return self.async_show_form( +# step_id="user", +# data_schema=vol.Schema( +# { +# vol.Required( +# CONF_WEBSOCKET_IP, +# default=self.config_entry.data.get(CONF_WEBSOCKET_IP), +# ): str, +# vol.Required( +# CONF_WEBSOCKET_PORT, +# default=self.config_entry.data.get(CONF_WEBSOCKET_PORT), +# ): int, +# } +# ), +# # data_schema=vol.Schema( +# # { +# # vol.Required(x, default=self.options.get(x, True)): bool +# # for x in sorted(PLATFORMS) +# # } +# # ), +# ) diff --git a/custom_components/ferroamp_portal/const.py b/custom_components/ferroamp_portal/const.py new file mode 100644 index 0000000..e68b3df --- /dev/null +++ b/custom_components/ferroamp_portal/const.py @@ -0,0 +1,35 @@ +"""Constants""" +# Base component constants +NAME = "Ferroamp Portal" +MANUFACTURER = "Ferroamp" +DOMAIN = "ferroamp_portal" +DOMAIN_DATA = f"{DOMAIN}_data" +ATTRIBUTION = "Data provided by Ferroamp Portal" +ISSUE_URL = "https://github.com/bj00rn/ha-ferroamp-portal/issues" +# Icons +ICON = "mdi:format-quote-close" + +# Platforms +SENSOR = "sensor" +SWITCH = "switch" +CLIMATE = "climate" +PLATFORMS = [SENSOR] + + +# Configuration and options +CONF_ENABLED = "enabled" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_SYSTEM_ID = "system_id" + +# Defaults +DEFAULT_NAME = DOMAIN + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} %s +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" diff --git a/custom_components/ferroamp_portal/coordinator.py b/custom_components/ferroamp_portal/coordinator.py new file mode 100644 index 0000000..fcafedd --- /dev/null +++ b/custom_components/ferroamp_portal/coordinator.py @@ -0,0 +1,26 @@ +"""Data update coordinator""" + +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import ApiClient +from .const import DOMAIN + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class FerroampPortalDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__(self, hass: HomeAssistant, client: ApiClient, update_interval) -> None: + """Initialize.""" + self.platforms = [] + self.client = client + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Fetch the latest data from the source.""" + data = self.client.data + return data diff --git a/custom_components/ferroamp_portal/entity.py b/custom_components/ferroamp_portal/entity.py new file mode 100644 index 0000000..c20ea38 --- /dev/null +++ b/custom_components/ferroamp_portal/entity.py @@ -0,0 +1,31 @@ +"""Entity""" + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .const import DEFAULT_NAME, DOMAIN, MANUFACTURER +from .coordinator import FerroampPortalDataUpdateCoordinator + + +class FerroampPortalEntity(CoordinatorEntity): + """Entity base class""" + + def __init__( + self, + coordinator: FerroampPortalDataUpdateCoordinator, + entry_id, + entity_description: EntityDescription, + ) -> None: + super().__init__(coordinator) + self._id = entry_id + self.entity_description = entity_description + self._attr_name = entity_description.name + self._attr_unique_id = f"{entry_id}_{slugify(entity_description.name)}" + self._id = entry_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name=DEFAULT_NAME, + manufacturer=MANUFACTURER, + ) diff --git a/custom_components/ferroamp_portal/manifest.json b/custom_components/ferroamp_portal/manifest.json new file mode 100644 index 0000000..3224f50 --- /dev/null +++ b/custom_components/ferroamp_portal/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "ferroamp_portal", + "name": "Ferroamp Portal", + "documentation": "https://github.com/bj00rn/ha-ferroamp-portal", + "iot_class": "local_push", + "issue_tracker": "https://github.com/bj00rn/ha-ferroamp-portal", + "version": "0.0.1", + "config_flow": true, + "codeowners": [ + "@bj00rn" + ], + "requirements": [ + "aiohttp" + ] +} diff --git a/custom_components/ferroamp_portal/sensor.py b/custom_components/ferroamp_portal/sensor.py new file mode 100644 index 0000000..229fa26 --- /dev/null +++ b/custom_components/ferroamp_portal/sensor.py @@ -0,0 +1,114 @@ +"""Sensor platform""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import slugify + +from .const import DEFAULT_NAME, DOMAIN +from .entity import FerroampPortalEntity + + +class FerroampPortalSensor(FerroampPortalEntity, SensorEntity): + """Sensor base class.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry_id, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_id = f"sensor.${DEFAULT_NAME}_${slugify(entity_description.name)}" + + super().__init__(coordinator, entry_id, entity_description) + + @property + def native_value(self): + """Return the native value of the sensor.""" + value = self.coordinator.data.get(self.entity_description.key) + if value: + value = value[0] if isinstance(value, list) else value + return value + + +# {'terminalId': '4:0', 'timestamp': '2023-02-20T13:56:54.818Z', 'powerActive': 3.9000000953674316, 'energyActiveMeter': 1103357, 'currentL1': 0.5, 'currentL2': 0.5, 'currentL3': 0.5, '__typename': 'EvseMeterValue'} + +sensors = { + "powerActive": { + "klass": FerroampPortalSensor, + "description": SensorEntityDescription( + key="powerActive", + icon="mdi:wrench-clock", + name="EV Active power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + ), + }, + "energyActiveMeter": { + "klass": FerroampPortalSensor, + "description": SensorEntityDescription( + key="energyActiveMeter", + icon="mdi:wrench-clock", + name="EV Energy meter", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + }, + "currentL1": { + "klass": FerroampPortalSensor, + "description": SensorEntityDescription( + key="currentL1", + icon="mdi:wrench-clock", + name="EV Current L1", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=1, + ), + }, + "currentL2": { + "klass": FerroampPortalSensor, + "description": SensorEntityDescription( + key="currentL2", + icon="mdi:wrench-clock", + name="EV Current L2", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=1, + ), + }, + "currentL3": { + "klass": FerroampPortalSensor, + "description": SensorEntityDescription( + key="currentL3", + icon="mdi:wrench-clock", + name="EV Current L3", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=1, + ), + }, +} + + +async def async_setup_entry(hass, entry, async_add_entities: AddEntitiesCallback): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + sensor.get("klass")(coordinator, entry.entry_id, sensor.get("description")) + for sensor in sensors.values() + ] + + async_add_entities(entities) diff --git a/custom_components/ferroamp_portal/strings.json b/custom_components/ferroamp_portal/strings.json new file mode 100644 index 0000000..5449c39 --- /dev/null +++ b/custom_components/ferroamp_portal/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter Ferroamp Portal credentials", + "data": { + "name": "Name", + "username": "Username", + "password": "Password", + "system_id": "Facility Id" + } + } + }, + "error": { + "connect": "Could not connect. Verify connection details" + }, + "abort": { + "single_instance_allowed": "Only a single instance is allowed." + } + } +} \ No newline at end of file diff --git a/custom_components/ferroamp_portal/translations/en.json b/custom_components/ferroamp_portal/translations/en.json new file mode 100644 index 0000000..5449c39 --- /dev/null +++ b/custom_components/ferroamp_portal/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter Ferroamp Portal credentials", + "data": { + "name": "Name", + "username": "Username", + "password": "Password", + "system_id": "Facility Id" + } + } + }, + "error": { + "connect": "Could not connect. Verify connection details" + }, + "abort": { + "single_instance_allowed": "Only a single instance is allowed." + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..fea0987 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Ferroamp Portal", + "render_readme": true +}