From d519eb5feec17545a05de2a63bb4c1d1525156b6 Mon Sep 17 00:00:00 2001 From: Sebastian Schneider Date: Tue, 7 Feb 2023 15:15:43 +0100 Subject: [PATCH] Initial version --- .github/FUNDING.yml | 1 + .github/assets/attendance.png | Bin 0 -> 3625 bytes .github/workflows/hacs.yml | 17 +++ .github/workflows/hassfest.yml | 14 +++ .gitignore | 8 ++ CHANGELOG.md | 5 + LICENSE | 21 ++++ README.md | 45 +++++++- custom_components/personio/__init__.py | 96 ++++++++++++++++ custom_components/personio/api/__init.py__ | 0 custom_components/personio/api/attendances.py | 71 ++++++++++++ .../personio/api/authentication.py | 104 ++++++++++++++++++ custom_components/personio/api/base.py | 1 + custom_components/personio/api/employees.py | 26 +++++ custom_components/personio/config_flow.py | 80 ++++++++++++++ custom_components/personio/const.py | 8 ++ custom_components/personio/entity.py | 25 +++++ custom_components/personio/manifest.json | 13 +++ custom_components/personio/strings.json | 21 ++++ custom_components/personio/switch.py | 87 +++++++++++++++ .../personio/translations/en.json | 21 ++++ hacs.json | 4 + 22 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/assets/attendance.png create mode 100644 .github/workflows/hacs.yml create mode 100644 .github/workflows/hassfest.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 custom_components/personio/__init__.py create mode 100644 custom_components/personio/api/__init.py__ create mode 100644 custom_components/personio/api/attendances.py create mode 100644 custom_components/personio/api/authentication.py create mode 100644 custom_components/personio/api/base.py create mode 100644 custom_components/personio/api/employees.py create mode 100644 custom_components/personio/config_flow.py create mode 100644 custom_components/personio/const.py create mode 100644 custom_components/personio/entity.py create mode 100644 custom_components/personio/manifest.json create mode 100644 custom_components/personio/strings.json create mode 100644 custom_components/personio/switch.py create mode 100644 custom_components/personio/translations/en.json create mode 100644 hacs.json diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3f0d5d7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ['https://buymeacoffee.com/seseschneider'] diff --git a/.github/assets/attendance.png b/.github/assets/attendance.png new file mode 100644 index 0000000000000000000000000000000000000000..167f485da3a40ceead1750e1ffb6f4d071645079 GIT binary patch literal 3625 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 BR(${f literal 0 HcmV?d00001 diff --git a/.github/workflows/hacs.yml b/.github/workflows/hacs.yml new file mode 100644 index 0000000..2d1876d --- /dev/null +++ b/.github/workflows/hacs.yml @@ -0,0 +1,17 @@ +name: HACS + +on: + workflow_dispatch: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + hacs: + name: HACS Action + runs-on: "ubuntu-latest" + steps: + - uses: actions/checkout@v3 + - uses: hacs/action@main + with: + category: integration diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml new file mode 100644 index 0000000..da4d72f --- /dev/null +++ b/.github/workflows/hassfest.yml @@ -0,0 +1,14 @@ +name: hassfest + +on: + workflow_dispatch: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - uses: home-assistant/actions/hassfest@master diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9489f64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/node_modules/ +/.rpt2_cache/ +/.idea/ +/dist/ + +__pycache__/ + +*.iml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9e5a566 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## 1.0.0 (2023-02-07) + +### Features + +* Initial Release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6893c34 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Custom cards for Home Assistant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ab8984f..4974a44 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ -# ha-personio -Integration with the Personio API for Home Assistant. +# Personio integration by [@Sese-Schneider](https://www.github.com/Sese-Schneider) + +A Home Assistant integration with the Personio API. + + +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) +[![GitHub Release][releases-shield]][releases] +![GitHub Downloads][downloads-shield] + +[![License][license-shield]](LICENSE) +![Project Maintenance][maintenance-shield] +[![GitHub Activity][commits-shield]][commits] + + +[!["Buy Me A Coffee"](https://buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/seseschneider) + +**Features:** + +- Automatically adding attendances through an added `switch`. + +![](.github/assets/attendance.png) + +## Install + +### HACS + +*This repo can be installed as a custom repository in HACS.* + +* Go to HACS → Integration +* Click on the three-dot-menu → Custom repositories +* Add `Sese-Schneider/ha-personio` as Integration. +* Use the FAB "Explore and download repositories" to search "Personio". +* Restart Home Assistant +* Install "Personio" as an integration in your settings. + + +[commits-shield]: https://img.shields.io/github/commit-activity/y/Sese-Schneider/ha-personio.svg?style=for-the-badge +[commits]: https://github.com/Sese-Schneider/ha-personio/commits/master +[downloads-shield]: https://img.shields.io/github/downloads/Sese-Schneider/ha-personio/total.svg?style=for-the-badge +[license-shield]: https://img.shields.io/github/license/Sese-Schneider/ha-personio.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/maintenance/yes/2023.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/Sese-Schneider/ha-personio.svg?style=for-the-badge +[releases]: https://github.com/Sese-Schneider/ha-personio/releases diff --git a/custom_components/personio/__init__.py b/custom_components/personio/__init__.py new file mode 100644 index 0000000..831fc62 --- /dev/null +++ b/custom_components/personio/__init__.py @@ -0,0 +1,96 @@ +"""Personio integration.""" + +from __future__ import annotations +import logging + +from requests import HTTPError + +from homeassistant.helpers.template import now + +from .const import CONF_USER, COORDINATOR, DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api.authentication import Authentication +from .api.attendances import Attendances +from .api.employees import Employees + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up entry.""" + + config = entry.as_dict() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + COORDINATOR: None, + } + + authentication = Authentication() + coordinator = PersonioUpdateCoordinator( + hass, authentication, config["data"][CONF_USER] + ) + + hass.data[DOMAIN][entry.entry_id][COORDINATOR] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.async_add_executor_job(authentication.set_config, config) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class PersonioUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage personio API calls single endpoint.""" + + def __init__( + self, hass: HomeAssistant, authentication: Authentication, user_email: str + ) -> None: + """Initialize global Personio API coordiantor.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=None, + ) + self._hass = hass + self._authentication = authentication + self._employees_api = Employees(authentication) + self._attendance_api = Attendances(authentication) + + self._user_email = user_email + self._user_id = None + + async def get_user_id(self) -> int: + """Get a users ID through their email.""" + if self._user_id: + return self._user_id + + self._user_id = await self._hass.async_add_executor_job( + self._employees_api.get_employee_id_by_mail, + self._user_email, + ) + + return self._user_id + + async def add_attendance(self, start_time: float, end_time: float): + """Add users attendance data.""" + uid = await self.get_user_id() + + self._hass.async_add_executor_job( + self._attendance_api.add_attendance, + uid, + start_time, + end_time, + now(self._hass) + ) diff --git a/custom_components/personio/api/__init.py__ b/custom_components/personio/api/__init.py__ new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/personio/api/attendances.py b/custom_components/personio/api/attendances.py new file mode 100644 index 0000000..247588f --- /dev/null +++ b/custom_components/personio/api/attendances.py @@ -0,0 +1,71 @@ +"""Attendances for the Persionio API.""" + +from datetime import datetime, timedelta +import logging +import requests +from config.custom_components.personio.api.authentication import Authentication +from config.custom_components.personio.api.base import BASE_URL + +_LOGGER = logging.getLogger(__name__) + + +class Attendances: + """Attendances for the Persionio API.""" + + def __init__(self, authentication: Authentication) -> None: + self._authentication = authentication + + def add_attendance( + self, + employee_id: int, + start_time: float, + end_time: float, + now: datetime, + ): + """Add attendances to the Personio API.""" + + attendances = [] + + start_date = datetime.fromtimestamp(start_time, tz=now.tzinfo) + end_date = datetime.fromtimestamp(end_time, tz=now.tzinfo) + + for single_date in _daterange(start_date, end_date): + attendances.append( + { + "employee": employee_id, + "date": single_date.strftime("%Y-%m-%d"), + "start_time": start_date.strftime("%H:%M") + if _is_on_date(start_date, single_date) + else "00:00", + "end_time": end_date.strftime("%H:%M") + if _is_on_date(end_date, single_date) + else "23:59", + "break": 0, + "project_id": None, + "comment": "Generated by Sese-Schneider/ha-personio", + } + ) + + result = requests.post( + BASE_URL + "/company/attendances", + headers=self._authentication.get_headers(), + timeout=10000, + json={"attendances": attendances}, + ) + result.raise_for_status() + self._authentication.set_response(result) + + _LOGGER.info("Attendance for employee %s added successfully", employee_id) + + +def _daterange(start_date: datetime, end_date: datetime): + start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) + end_date = end_date.replace(hour=0, minute=0, second=0, microsecond=0) + days = int((end_date - start_date).days) + + for n in range(days + 1): # pylint: disable=invalid-name + yield start_date + timedelta(days=n) + + +def _is_on_date(to_check: datetime, on_date: datetime) -> bool: + return on_date < to_check < on_date + timedelta(days=1) diff --git a/custom_components/personio/api/authentication.py b/custom_components/personio/api/authentication.py new file mode 100644 index 0000000..9bcd035 --- /dev/null +++ b/custom_components/personio/api/authentication.py @@ -0,0 +1,104 @@ +"""Authentication for the Personio API""" + +import logging +import time +import jwt +import requests +from requests import Response + +from config.custom_components.personio.api.base import BASE_URL +from homeassistant.exceptions import HomeAssistantError + +_LOGGER = logging.getLogger(__name__) + +class Authentication: + """Authentication for the Personio API.""" + + _current_config: dict = None + _current_token: str = None + + def set_config(self, config: dict): + """Set the current API configuration.""" + self._current_config = config["data"] + + # self test current auth config, fetch initial token for usage + self.get_bearer(invalidate=False) + + def get_bearer(self, invalidate: bool = True): + """Get a currently valid authentication bearer.""" + + if not self._current_config: + raise HomeAssistantError("Config not defined.") + + if self._current_token: + jwt_token = jwt.decode( + self._current_token, + algorithms=["HS256"], + options={"verify_signature": False}, + ) + if jwt_token["exp"] > time.time(): + _LOGGER.debug("Reusing existing JWT token") + bearer = "Bearer " + self._current_token + if invalidate: + # bearers are one-time use only + _LOGGER.debug("Invalidating JWT token") + self._current_token = None + + return bearer + + _LOGGER.debug("Requesting new JWT token") + authentication = authenticate( + self._current_config["client_id"], + self._current_config["client_secret"], + self._current_config["partner_id"], + self._current_config["app_id"], + ) + + self._current_token = authentication.json()["data"]["token"] + return self.get_bearer(invalidate=invalidate) + + + def get_headers(self): + """Returns all headers required for a successful Personio API request.""" + partner_id = self._current_config["partner_id"] + app_id = self._current_config["app_id"] + + headers = { + "Authorization": self.get_bearer() + } + + if partner_id: + headers["X-Personio-Partner-ID"] = partner_id + if app_id: + headers["X-Personio-App-ID"] = app_id + return headers + + + def set_response(self, response: Response): + """Callback after receiving a new response. + Call this to set new authorization headers after each successful request.""" + self._current_token = response.headers["authorization"].removeprefix("Bearer ") + _LOGGER.debug("New JWT token received") + + +def authenticate( + client_id: str, + client_secret: str, + partner_id: str = None, + app_id: str = None, +): + """Authenticate agains the Personio API.""" + headers = {} + if partner_id: + headers["X-Personio-Partner-ID"] = partner_id + if app_id: + headers["X-Personio-App-ID"] = app_id + + result = requests.post( + BASE_URL + "/auth", + params={"client_id": client_id, "client_secret": client_secret}, + headers=headers, + timeout=10000, + ) + result.raise_for_status() + return result diff --git a/custom_components/personio/api/base.py b/custom_components/personio/api/base.py new file mode 100644 index 0000000..cf80eba --- /dev/null +++ b/custom_components/personio/api/base.py @@ -0,0 +1 @@ +BASE_URL = "https://api.personio.de/v1" diff --git a/custom_components/personio/api/employees.py b/custom_components/personio/api/employees.py new file mode 100644 index 0000000..25bc33c --- /dev/null +++ b/custom_components/personio/api/employees.py @@ -0,0 +1,26 @@ +"""Employees for the Persionio API.""" + +import requests +from config.custom_components.personio.api.authentication import Authentication +from config.custom_components.personio.api.base import BASE_URL + + +class Employees: + """Employees for the Persionio API.""" + + def __init__(self, authentication: Authentication) -> None: + self._authentication = authentication + + def get_employee_id_by_mail(self, employee_email: int) -> bool: + """Get Employees from the Personio API.""" + + result = requests.get( + BASE_URL + "/company/employees", + headers=self._authentication.get_headers(), + params={"email": employee_email}, + timeout=10000, + ) + result.raise_for_status() + self._authentication.set_response(result) + + return result.json()["data"][0]["attributes"]["id"]["value"] diff --git a/custom_components/personio/config_flow.py b/custom_components/personio/config_flow.py new file mode 100644 index 0000000..07ce0df --- /dev/null +++ b/custom_components/personio/config_flow.py @@ -0,0 +1,80 @@ +"""Personio config flow.""" + +from typing import Any +from .api.authentication import authenticate + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.data_entry_flow import FlowResult +from requests import HTTPError + +from .const import CONF_USER, DOMAIN, CONF_PARTNER_ID, CONF_APP_ID + + +class PersonioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Personio config flow.""" + + # The schema version of the entries that it creates + # Home Assistant will call your migrate method if the version changes + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Initial configuration step.""" + data_schema = vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_PARTNER_ID): cv.string, + vol.Optional(CONF_APP_ID): cv.string, + vol.Required(CONF_USER): cv.string, + } + ) + + if user_input is None: + return self.async_show_form(step_id="user", data_schema=data_schema) + + data = { + CONF_CLIENT_ID: user_input[CONF_CLIENT_ID], + CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET], + CONF_PARTNER_ID: user_input.get(CONF_PARTNER_ID), + CONF_APP_ID: user_input.get(CONF_APP_ID), + CONF_USER: user_input.get(CONF_USER), + } + + return await self._validate_and_create("user", data_schema, data); + + async def _validate_and_create( + self, step_id: str, data_schema: vol.Schema, data: dict + ) -> FlowResult: + """Validate data and show form if it is invalid.""" + errors: dict[str, str] = {} + + # noinspection PyBroadException + try: + await self.hass.async_add_executor_job( + authenticate, + data[CONF_CLIENT_ID], + data[CONF_CLIENT_SECRET], + data[CONF_PARTNER_ID], + data[CONF_APP_ID], + ) + except HTTPError as err: + if err.response.status_code == 403: + errors["base"] = "invalid_auth" + else: + errors["base"] = "unknown" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Personio", + data=data, + ) + + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=errors, + ) diff --git a/custom_components/personio/const.py b/custom_components/personio/const.py new file mode 100644 index 0000000..a1e233d --- /dev/null +++ b/custom_components/personio/const.py @@ -0,0 +1,8 @@ +# The domain of your component. Should be equal to the name of your component. +DOMAIN = "personio" + +CONF_PARTNER_ID = "partner_id" +CONF_APP_ID = "app_id" +CONF_USER = "user_id" + +COORDINATOR = "coordinator" diff --git a/custom_components/personio/entity.py b/custom_components/personio/entity.py new file mode 100644 index 0000000..49631b6 --- /dev/null +++ b/custom_components/personio/entity.py @@ -0,0 +1,25 @@ +from homeassistant.components.switch import SwitchDeviceClass +from . import PersonioUpdateCoordinator +from .const import DOMAIN +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +class PersonioAttendanceEntity(CoordinatorEntity[PersonioUpdateCoordinator]): + """Representation of a attendance entity.""" + + def __init__( + self, + coordinator: PersonioUpdateCoordinator, + entity_suffix: str, + ) -> None: + """Init from config, hookup enitity and coordinator.""" + super().__init__(coordinator) + + self._attr_name = "Personio Attendance" + self._attr_unique_id = f"{DOMAIN}_attendance_{entity_suffix}" + self._attr_device_class = SwitchDeviceClass.SWITCH + self._attr_icon = "mdi:briefcase" + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return True \ No newline at end of file diff --git a/custom_components/personio/manifest.json b/custom_components/personio/manifest.json new file mode 100644 index 0000000..f0b775a --- /dev/null +++ b/custom_components/personio/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "personio", + "name": "Personio", + "documentation": "https://github.com/Sese-Schneider/ha-personio", + "issue_tracker": "https://github.com/Sese-Schneider/ha-personio/issues", + "integration_type": "hub", + "dependencies": [], + "requirements": ["pyjwt"], + "iot_class": "cloud_polling", + "version": "1.0.0", + "codeowners": ["@Sese-Schneider"], + "config_flow": true +} diff --git a/custom_components/personio/strings.json b/custom_components/personio/strings.json new file mode 100644 index 0000000..cff3f53 --- /dev/null +++ b/custom_components/personio/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Personio API credentials", + "description": "Your credentials to the Personio API", + "data": { + "client_id": "Client ID", + "client_secret": "Client secret", + "partner_id": "Partner identifier", + "app_id": "Application identifier", + "user_id": "Personio employee user email" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/custom_components/personio/switch.py b/custom_components/personio/switch.py new file mode 100644 index 0000000..35b69e8 --- /dev/null +++ b/custom_components/personio/switch.py @@ -0,0 +1,87 @@ +"""Platform for binary sensor integration.""" + +from dataclasses import dataclass +import time +from typing import Any + +from . import PersonioUpdateCoordinator +from .const import COORDINATOR, DOMAIN +from .entity import PersonioAttendanceEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.components.switch import ( + SwitchEntity, + SwitchEntityDescription, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + + +ATTR_IS_ON = "attr_is_on" +ATTR_TURN_ON_TIME = "attr_turn_on_time" + + +@dataclass +class PersonioAttendanceSwitchEntityDescription(SwitchEntityDescription): + """Describes a Tailscale binary sensor entity.""" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Personio binary sensors-""" + + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + + async_add_entities([PersonioAttendanceSwitch(coordinator, entry.entry_id)]) + + +class PersonioAttendanceSwitch(PersonioAttendanceEntity, SwitchEntity, RestoreEntity): + """Define an Personio Attendance binary sensor.""" + + entity_description: PersonioAttendanceSwitchEntityDescription + + def __init__(self, coordinator: PersonioUpdateCoordinator, suffix: str) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, suffix) + self._turn_on_time = None + self._coordinator = coordinator + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + # No-op for turn on. Data is written on turn off. + self._attr_is_on = True + self._turn_on_time = time.time() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._coordinator.add_attendance(self._turn_on_time, time.time()) + self._turn_on_time = None + self._attr_is_on = False + self.async_write_ha_state() + + # State restoration + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + return { + ATTR_IS_ON: self._attr_is_on, + ATTR_TURN_ON_TIME: self._turn_on_time, + } + + async def async_added_to_hass(self) -> None: + """Restore previous state on restart to avoid blocking startup.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + + if last_state is not None: + attributes = last_state.attributes + keys = attributes.keys() + if ATTR_IS_ON in keys: + self._attr_is_on = attributes[ATTR_IS_ON] + if ATTR_TURN_ON_TIME in keys: + self._turn_on_time = attributes[ATTR_TURN_ON_TIME] diff --git a/custom_components/personio/translations/en.json b/custom_components/personio/translations/en.json new file mode 100644 index 0000000..cff3f53 --- /dev/null +++ b/custom_components/personio/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Personio API credentials", + "description": "Your credentials to the Personio API", + "data": { + "client_id": "Client ID", + "client_secret": "Client secret", + "partner_id": "Partner identifier", + "app_id": "Application identifier", + "user_id": "Personio employee user email" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..f26279f --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Personio", + "render_readme": true +}