commit c8d5c9115aff20a9b0025dfc16f8a3e3ca54ceb1 Author: Roger Gonzalez Date: Sat Sep 23 18:59:01 2023 -0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed63c92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +.idea +*.log +tmp/ + +*.py[cod] +*.egg +build +htmlcov + +# Custom +script.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9931e3a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,58 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.3.0 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/codespell-project/codespell + rev: v1.16.0 + hooks: + - id: codespell + args: + - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing + - --skip="./.*,*.csv,*.json" + - --quiet-level=2 + exclude_types: [csv, json] + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.1 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.2 + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/bandit + rev: 1.6.2 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=tests/bandit.yaml + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.770 + hooks: + - id: mypy + args: + - --pretty + - --show-error-codes + - --show-error-context diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c44b92 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# UTE for Home Assistant + +## Installation diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/ute/__init__.py b/custom_components/ute/__init__.py new file mode 100644 index 0000000..dee8a8d --- /dev/null +++ b/custom_components/ute/__init__.py @@ -0,0 +1,26 @@ +import logging + +from homeassistant import config_entries, core + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up platform from a ConfigEntry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = entry.data + + # Forward the setup to the sensor platform. + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True + + +async def async_setup(hass: core.HomeAssistant, config: dict) -> bool: + hass.data.setdefault(DOMAIN, {}) + return True diff --git a/custom_components/ute/config_flow.py b/custom_components/ute/config_flow.py new file mode 100644 index 0000000..61064f1 --- /dev/null +++ b/custom_components/ute/config_flow.py @@ -0,0 +1,89 @@ +import re +import logging +from typing import Any, Dict, Optional + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONF_PHONE_NUMBER = "phone_number" +CONF_UTE_DEVICE_ID = "ute_device_id" +CONF_UTE_AVERAGE_COST_PER_KWH = "average_cost_per_kwh" + +schema = { + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PHONE_NUMBER): cv.string, + vol.Optional(CONF_UTE_DEVICE_ID): cv.string, + vol.Optional(CONF_UTE_AVERAGE_COST_PER_KWH): cv.string, +} + +AUTH_SCHEMA = vol.Schema(schema) + + +def validate_email(email: str) -> None: + """ + Validates a email address + + Args: + email: The email address to validate. + + Raises: + ValueError: If the email address is invalid. + """ + if not re.match(r"[^@]+@[^@]+\.[^@]+", email): + raise ValueError + + +async def validate_uyu_phone_number(phone_number: str) -> None: + """ + Validates a Uruguayan phone number + + Args: + phone_number: The phone number to validate. + + Raises: + ValueError: If the phone number is invalid. + """ + if not phone_number.startswith("598"): + raise ValueError + + if not re.match(r"^[0-9]{11}$", phone_number): + raise ValueError + + +class UTEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """ + UTE Custom config flow. + + Args: + config_entries: The config entries. + domain: The domain. + + Returns: + The config flow. + """ + + data: Optional[Dict[str, Any]] + + async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): + """Invoked when a user initiates a flow via the user interface.""" + errors: Dict[str, str] = {} + if user_input is not None: + try: + validate_email(user_input[CONF_EMAIL]) + validate_uyu_phone_number(user_input[CONF_PHONE_NUMBER]) + except ValueError: + errors["base"] = "auth" + if not errors: + # Input is valid, set data. + self.data = user_input + return await self.async_step_repo() + + return self.async_show_form( + step_id="user", data_schema=AUTH_SCHEMA, errors=errors + ) diff --git a/custom_components/ute/const.py b/custom_components/ute/const.py new file mode 100644 index 0000000..e763490 --- /dev/null +++ b/custom_components/ute/const.py @@ -0,0 +1 @@ +DOMAIN = "ute" diff --git a/custom_components/ute/manifest.json b/custom_components/ute/manifest.json new file mode 100644 index 0000000..698959a --- /dev/null +++ b/custom_components/ute/manifest.json @@ -0,0 +1,11 @@ +{ + "codeowners": ["@rogsme"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/rogsme/homeassistant_ute", + "domain": "ute", + "iot_class": "calculated", + "name": "UTE", + "requirements": ["ute-wrapper==1.0.3"], + "version": "1.0.0" +} diff --git a/custom_components/ute/sensor.py b/custom_components/ute/sensor.py new file mode 100644 index 0000000..f71efa2 --- /dev/null +++ b/custom_components/ute/sensor.py @@ -0,0 +1,75 @@ +from datetime import timedelta +import logging +from typing import Callable, Optional + +from ute_wrapper.ute import UTEClient +from homeassistant import config_entries, core +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_EMAIL, CONF_PHONE_NUMBER, CONF_UTE_DEVICE_ID, CONF_UTE_AVERAGE_COST_PER_KWH +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + HomeAssistantType, +) + +from .const import DOMAIN +from .config_flow import schema + +_LOGGER = logging.getLogger(__name__) +# Time between updating data from UTE +SCAN_INTERVAL = timedelta(minutes=2) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(schema) + + +async def async_setup_entry( + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities, +): + """Setup sensors from a config entry created in the integrations UI.""" + config = hass.data[DOMAIN][config_entry.entry_id] + ute = UTEClient( + config[CONF_EMAIL], + config[CONF_PHONE_NUMBER], + config[CONF_UTE_DEVICE_ID], + config[CONF_UTE_AVERAGE_COST_PER_KWH], + ) + sensor = UTESensor(ute) + async_add_entities(sensor, update_before_add=True) + + +async def async_setup_platform( + hass: HomeAssistantType, + config: ConfigType, + async_add_entities: Callable, + discovery_info: Optional[DiscoveryInfoType] = None, +) -> None: + """Set up the sensor platform.""" + ute = UTEClient( + config[CONF_EMAIL], + config[CONF_PHONE_NUMBER], + config[CONF_UTE_DEVICE_ID], + config[CONF_UTE_AVERAGE_COST_PER_KWH], + ) + sensor = UTESensor(ute) + async_add_entities(sensor, update_before_add=True) + + +class UTESensor(Entity): + """Representation of a UTE sensor.""" + + def __init__(self, ute: UTEClient): + super().__init__() + self.ute = ute + self._state = None + self._available = True + self._name = "Current energy usage" + + async def async_update(self): + try: + ute_data = await self.ute.get_current_usage_info() + self._state = ute_data["data"]["power_in_watts"] + except (): + pass diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..ce5f1b0 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "UTE", + "render_readme": true, + "iot_class": "calculated" +} diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..efeebaa --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,3 @@ +pytest +pytest-cov==2.9.0 +pytest-homeassistant-custom-component diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6197927 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,62 @@ +[coverage:run] +source = + custom_components + +[coverage:report] +exclude_lines = + pragma: no cover + raise NotImplemented() + if __name__ == '__main__': + main() +show_missing = true + +[tool:pytest] +testpaths = tests +norecursedirs = .git +addopts = + --strict + --cov=custom_components + +[flake8] +# https://github.com/ambv/black#line-length +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components,tests +forced_separate = tests +combine_as_imports = true + +[mypy] +python_version = 3.7 +ignore_errors = true +follow_imports = silent +ignore_missing_imports = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bandit.yaml b/tests/bandit.yaml new file mode 100644 index 0000000..ebd284e --- /dev/null +++ b/tests/bandit.yaml @@ -0,0 +1,17 @@ +# https://bandit.readthedocs.io/en/latest/config.html + +tests: + - B108 + - B306 + - B307 + - B313 + - B314 + - B315 + - B316 + - B317 + - B318 + - B319 + - B320 + - B325 + - B602 + - B604 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..0f8c068 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,9 @@ +"""Test component setup.""" +from homeassistant.setup import async_setup_component + +from custom_components.ute.const import DOMAIN + + +async def test_async_setup(hass): + """Test the component gets setup.""" + assert await async_setup_component(hass, DOMAIN, {}) is True