diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..6c56119 --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,25 @@ +#Limited configuration instead of default_config +#https://github.com/home-assistant/core/tree/dev/homeassistant/components/default_config +automation: +frontend: +history: +logbook: + +homeassistant: + name: Home + +variable: + test_sensor: + value: 0 + restore: true + domain: sensor + + test_counter: + value: 0 + attributes: + icon: mdi:alarm + +logger: + default: info + logs: + custom_components.variable: debug diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8ff8c96 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,34 @@ +{ + "name": "Home Assistant Custom Component Dev", + "context": "..", + "image": "ghcr.io/ludeeus/devcontainer/integration:latest", + "appPort": "9123:8123", + "postCreateCommand": "container install && pip install --upgrade pip && pip install --ignore-installed -r requirements.txt", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ms-python.vscode-pylance", + "spmeesseman.vscode-taskexplorer" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/local/python/bin/python", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.linting.pylintArgs": [ + "--disable", + "import-error" + ], + "python.formatting.provider": "black", + "python.testing.pytestArgs": [ + "--no-cov" + ], + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9e261e0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text eol=lf +*.py whitespace=error + +*.ico binary +*.jpg binary +*.png binary +*.zip binary +*.mp3 binary \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7c5eeed --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.formatting.provider": "black", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "git.ignoreLimitWarning": true, +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7ab4ba8 --- /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.md b/README.md index c5c7a40..ae31b5e 100644 --- a/README.md +++ b/README.md @@ -42,19 +42,32 @@ variable: restore: true current_power_usage: force_update: true + + daily_download: + value: 0 + restore: true + domain: sensor + attributes: + state_class: measurement + unit_of_measurement: GB + icon: mdi:download ``` A variable 'should' have a __value__ and can optionally have a __name__ and __attributes__, which can be used to specify additional values but can also be used to set internal attributes like icon, friendly_name etc. +A variable can accept an optional `domain` which results in the entity name to start with that domain instead of `variable`. + In case you want your variable to restore its value and attributes after restarting you can set __restore__ to true. In case you want your variable to update (and add a history entry) even if the value has not changed, you can set __force_update__ to true. ## Set variables from automations -To update a variables value and/or its attributes you can use the service call `variable.set_variable` +The variable component exposes 2 services: +* `variable.set_variable` can be used to update a variables value and/or its attributes. +* `variable.set_entity` can be used to update an entity value and/or its attributes. -The following parameters can be used with this service: +The following parameters can be used with `variable.set_variable`: - __variable: string (required)__ The name of the variable to update @@ -65,7 +78,19 @@ Attributes to set or update - __replace_attributes: boolean ( optional )__ Replace or merge current attributes (default false = merge) -### Example service calls + +The following parameters can be used with `variable.set_entity`: + +- __entity: string (required)__ +The id of the entity to update +- __value: any (optional)__ +New value to set +- __attributes: dictionary (optional)__ +Attributes to set or update +- __replace_attributes: boolean ( optional )__ +Replace or merge current attributes (default false = merge) + +#### Example service calls ```yaml action: @@ -83,6 +108,12 @@ action: history_1: "{{states('variable.last_motion')}}" history_2: "{{state_attr('variable.last_motion','history_1')}}" history_3: "{{state_attr('variable.last_motion','history_2')}}" + +action: + - service: variable.set_entity + data: + variable: sensor.test_counter + value: 30 ``` ### Example timer automation diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..1bfbcae --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Dummy __init__.py to make imports with homeassistant-custom-component work.""" diff --git a/custom_components/variable/__init__.py b/custom_components/variable/__init__.py index 1eee279..5cfecfa 100644 --- a/custom_components/variable/__init__.py +++ b/custom_components/variable/__init__.py @@ -1,13 +1,13 @@ """variable implementation for Home Assistant.""" import logging -import voluptuous as vol - -from homeassistant.const import CONF_NAME, ATTR_ICON +from homeassistant.const import ATTR_ICON, CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +import voluptuous as vol _LOGGER = logging.getLogger(__name__) @@ -18,13 +18,26 @@ CONF_VALUE = "value" CONF_RESTORE = "restore" CONF_FORCE_UPDATE = "force_update" +CONF_DOMAIN = "domain" +ATTR_ENTITY = "entity" ATTR_VARIABLE = "variable" ATTR_VALUE = "value" ATTR_ATTRIBUTES = "attributes" ATTR_REPLACE_ATTRIBUTES = "replace_attributes" +ATTR_DOMAIN = "domain" +SERVICE_SET_ENTITY = "set_entity" SERVICE_SET_VARIABLE = "set_variable" + +SERVICE_SET_ENTITY_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY): cv.string, + vol.Optional(ATTR_VALUE): cv.match_all, + vol.Optional(ATTR_ATTRIBUTES): dict, + vol.Optional(ATTR_REPLACE_ATTRIBUTES): cv.boolean, + } +) SERVICE_SET_VARIABLE_SCHEMA = vol.Schema( { vol.Required(ATTR_VARIABLE): cv.string, @@ -45,6 +58,7 @@ vol.Optional(CONF_ATTRIBUTES): dict, vol.Optional(CONF_RESTORE): cv.boolean, vol.Optional(CONF_FORCE_UPDATE): cv.boolean, + vol.Optional(ATTR_DOMAIN): cv.string, }, None, ) @@ -55,28 +69,12 @@ ) -@bind_hass -def set_variable( - hass, - variable, - value, - attributes, - replace_attributes, -): - """Set input_boolean to True.""" - hass.services.call( - DOMAIN, - SERVICE_SET_VARIABLE, - { - ATTR_VARIABLE: variable, - ATTR_VALUE: value, - ATTR_ATTRIBUTES: attributes, - ATTR_REPLACE_ATTRIBUTES: replace_attributes, - }, - ) +def get_entity_id_format(domain: str) -> str: + """Get the entity id format.""" + return domain + ".{}" -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType): """Set up variables.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -91,9 +89,12 @@ async def async_setup(hass, config): attributes = variable_config.get(CONF_ATTRIBUTES) restore = variable_config.get(CONF_RESTORE, False) force_update = variable_config.get(CONF_FORCE_UPDATE, False) + domain = variable_config.get(CONF_DOMAIN, DOMAIN) entities.append( - Variable(variable_id, name, value, attributes, restore, force_update) + Variable( + variable_id, name, value, attributes, restore, force_update, domain + ) ) async def async_set_variable_service(call): @@ -108,7 +109,27 @@ async def async_set_variable_service(call): call.data.get(ATTR_REPLACE_ATTRIBUTES, False), ) else: - _LOGGER.warning(f"Failed to set unknown variable: {entity_id}") + _LOGGER.warning("Failed to set unknown variable: %s", entity_id) + + async def async_set_entity_service(call): + """Handle calls to the set_entity service.""" + + entity_id: str = call.data.get(ATTR_ENTITY) + state_value = call.data.get(ATTR_VALUE) + attributes = call.data.get(ATTR_ATTRIBUTES, {}) + replace_attributes = call.data.get(ATTR_REPLACE_ATTRIBUTES, False) + + if replace_attributes: + updated_attributes = attributes + else: + cur_state = hass.states.get(entity_id) + if cur_state is None or cur_state.attributes is None: + updated_attributes = attributes + else: + updated_attributes = dict(cur_state.attributes) + updated_attributes.update(attributes) + + hass.states.async_set(entity_id, state_value, updated_attributes) hass.services.async_register( DOMAIN, @@ -116,6 +137,12 @@ async def async_set_variable_service(call): async_set_variable_service, schema=SERVICE_SET_VARIABLE_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_ENTITY, + async_set_entity_service, + schema=SERVICE_SET_ENTITY_SCHEMA, + ) await component.async_add_entities(entities) return True @@ -124,9 +151,12 @@ async def async_set_variable_service(call): class Variable(RestoreEntity): """Representation of a variable.""" - def __init__(self, variable_id, name, value, attributes, restore, force_update): + def __init__( + self, variable_id, name, value, attributes, restore, force_update, domain + ): """Initialize a variable.""" - self.entity_id = ENTITY_ID_FORMAT.format(variable_id) + + self.entity_id = get_entity_id_format(domain).format(variable_id) self._name = name self._value = value self._attributes = attributes @@ -174,7 +204,7 @@ def state_attributes(self): @property def force_update(self) -> bool: - """Force update""" + """Force an update.""" return self._force_update async def async_set_variable( @@ -184,7 +214,6 @@ async def async_set_variable( replace_attributes, ): """Update variable.""" - current_state = self.hass.states.get(self.entity_id) updated_attributes = None updated_value = None diff --git a/custom_components/variable/services.yaml b/custom_components/variable/services.yaml index ed84527..de9ec43 100644 --- a/custom_components/variable/services.yaml +++ b/custom_components/variable/services.yaml @@ -8,9 +8,25 @@ set_variable: # Key of the field variable: description: string (required) The name of the variable to update + example: test_counter value: description: any (optional) New value to set + example: 9 attributes: description: dictionary (optional) Attributes to set or update replace_attributes: - description: boolean ( optional ) Replace or merge current attributes (default false = merge) \ No newline at end of file + description: boolean (optional) Replace or merge current attributes (default false = merge) + +set_entity: + description: Update an entity value and/or its attributes. + fields: + entity: + description: string (required) The id of the entity to update + example: sensor.test_sensor + value: + description: any (optional) New value to set + example: 9 + attributes: + description: dictionary (optional) Attributes to set or update + replace_attributes: + description: boolean (optional) Replace or merge current attributes (default false = merge) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..470ac5f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +# Custom requirements + +# Pre-commit requirements +codespell==2.1.0 +flake8-comprehensions==3.8.0 +flake8-docstrings==1.6.0 +flake8-noqa==1.2.1 +flake8==4.0.1 +isort==5.10.1 + +# Unit tests requirements +pytest==7.1.2 +pytest-cov==3.0.0 +pytest-homeassistant-custom-component \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..73b9666 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,41 @@ +[tool:pytest] +testpaths = tests +norecursedirs = .git +addopts = + --strict + --cov=custom_components + +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +# 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 +noqa-require-code = True + +[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 = " " +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components,tests +forced_separate = tests +combine_as_imports = true \ No newline at end of file