Skip to content

Commit

Permalink
Merge pull request #21 from Miicroo/feature/templated_attributes
Browse files Browse the repository at this point in the history
Add templated attributes
  • Loading branch information
Miicroo authored Oct 8, 2024
2 parents f0c0e01 + 0cac0d5 commit dc1a4e5
Show file tree
Hide file tree
Showing 11 changed files with 318 additions and 19 deletions.
22 changes: 22 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: pre-commit

on:
push:
pull_request:

jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10-dev'
- uses: pre-commit/[email protected]
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.test.txt
- name: Run pytest
run: |
pytest
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
**/.DS_Store
.history
.history
.idea
.mypy_cache
__pycache__/
.coverage
49 changes: 49 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v3.2.2
hooks:
- id: pyupgrade
args: [--py310-plus]
- repo: https://github.com/psf/black
rev: 22.10.0
hooks:
- id: black
args:
- --safe
- --quiet
files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$
- repo: https://github.com/codespell-project/codespell
rev: v2.2.2
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://github.com/PyCQA/flake8
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies:
- flake8-docstrings==1.5.0
- pydocstyle==5.0.2
files: ^(homeassistant|script|tests)/.+\.py$
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-executables-have-shebangs
stages: [manual]
- id: check-json
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991
hooks:
- id: mypy
args:
- --pretty
- --show-error-codes
- --show-error-context
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,53 @@ You can add a unique id and custom attributes to each birthday, for instance to
To do this, add a dictionary under the `attributes` key in the configuration (see example above). The dictionary can contain any key-value pairs you want, and will be exposed as attributes on the entity.
Fetching the attributes can be done using `state_attr` in a template, for instance `{{ state_attr('birthdays.einstein', 'occupation') }}` will return `Theoretical physicist`.

### Templated attributes
Attributes to an entity can also be a template. To do calculations based on data from the entity, use the `this`-keyword.
Be aware that if a template that cannot be correctly parsed it can lead to the entity not being loaded,
so if your entity is suddenly gone after adding a templated attribute, please check the logs.

Example calculating age in number of days:
```yaml
birthdays:
- name: 'Frodo Baggins'
date_of_birth: 1921-09-22
attributes:
days_since_birth: '{{ ((as_timestamp(now()) - as_timestamp(this.date_of_birth)) | int /60/1440) | int }}'
```

Properties of `this` that can be used:
* name
* unique_id
* state
* icon
* date_of_birth
* unit_of_measurement

Note: Don't use `this.extra_state_attributes`, as that might trigger an infinite loop.

### Global attributes:
It is possible to add global attributes that will be added to all birthdays. Global attributes work just the same as other attributes,
and can thus also be templated.

This example will add the attribute `days_since_birth` on all entities:
```yaml
# Example configuration.yaml entry
birthdays:
config:
attributes:
days_since_birth: '{{ ((as_timestamp(now()) - as_timestamp(this.date_of_birth)) | int /60/1440) | int }}'
birthdays:
- name: 'Frodo Baggins'
date_of_birth: 1921-09-22
- name: 'Bilbo Baggins'
date_of_birth: 1843-09-22
- name: Elvis
date_of_birth: 1935-01-08
icon: 'mdi:music'
```

Note that global attributes will be overridden by entity specific attributes.

## Automation
All birthdays are updated at midnight, and when a birthday occurs an event is sent on the HA bus that can be used for automations. The event is called `birthday` and contains the data `name` and `age`. Note that there will be two events fired if two persons have the same birthday.

Expand Down
1 change: 1 addition & 0 deletions custom_components/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
""" Custom components module"""
75 changes: 57 additions & 18 deletions custom_components/birthdays/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import asyncio
import logging

import async_timeout
import voluptuous as vol

import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
from homeassistant.util import slugify
from homeassistant.helpers.template import Template, is_template_string, render_complex
from homeassistant.util import dt as dt_util, slugify

_LOGGER = logging.getLogger(__name__)

Expand All @@ -18,37 +17,65 @@
CONF_DATE_OF_BIRTH = 'date_of_birth'
CONF_ICON = 'icon'
CONF_ATTRIBUTES = 'attributes'
CONF_GLOBAL_CONFIG = 'config'
CONF_BIRTHDAYS = 'birthdays'
CONF_AGE_AT_NEXT_BIRTHDAY = 'age_at_next_birthday'
DOMAIN = 'birthdays'

BIRTHDAY_CONFIG_SCHEMA = vol.Schema({
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Required(CONF_NAME) : cv.string,
vol.Required(CONF_DATE_OF_BIRTH) : cv.date,
vol.Optional(CONF_ICON, default = 'mdi:cake'): cv.string,
vol.Optional(CONF_ATTRIBUTES, default = {}) : vol.Schema({cv.string: cv.string}),
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_DATE_OF_BIRTH): cv.date,
vol.Optional(CONF_ICON, default='mdi:cake'): cv.string,
vol.Optional(CONF_ATTRIBUTES, default={}): vol.Schema({cv.string: cv.string}),
})

CONFIG_SCHEMA = vol.Schema({
GLOBAL_CONFIG_SCHEMA = vol.Schema({
vol.Optional(CONF_ATTRIBUTES, default={}): vol.Schema({cv.string: cv.string}),
})

# Old schema (list of birthday configurations)
OLD_CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.All(cv.ensure_list, [BIRTHDAY_CONFIG_SCHEMA])
}, extra=vol.ALLOW_EXTRA)

async def async_setup(hass, config):
# New schema (supports both global and birthday configs)
NEW_CONFIG_SCHEMA = vol.Schema({
DOMAIN: {
CONF_BIRTHDAYS: vol.All(cv.ensure_list, [BIRTHDAY_CONFIG_SCHEMA]),
vol.Optional(CONF_GLOBAL_CONFIG, default={}): GLOBAL_CONFIG_SCHEMA
}
}, extra=vol.ALLOW_EXTRA)

# Use vol.Any() to support both old and new schemas
CONFIG_SCHEMA = vol.Schema(vol.Any(
OLD_CONFIG_SCHEMA,
NEW_CONFIG_SCHEMA
), extra=vol.ALLOW_EXTRA)


async def async_setup(hass, config):
devices = []

for birthday_data in config[DOMAIN]:
is_new_config = isinstance(config[DOMAIN], dict) and config[DOMAIN].get(CONF_BIRTHDAYS) is not None
birthdays = config[DOMAIN][CONF_BIRTHDAYS] if is_new_config else config[DOMAIN]

for birthday_data in birthdays:
unique_id = birthday_data.get(CONF_UNIQUE_ID)
name = birthday_data[CONF_NAME]
date_of_birth = birthday_data[CONF_DATE_OF_BIRTH]
icon = birthday_data[CONF_ICON]
attributes = birthday_data[CONF_ATTRIBUTES]
if is_new_config:
global_config = config[DOMAIN][CONF_GLOBAL_CONFIG] # Empty dict or has attributes
global_attributes = global_config.get(CONF_ATTRIBUTES) or {}
attributes = dict(global_attributes, **attributes) # Add global_attributes but let local attributes be on top

devices.append(BirthdayEntity(unique_id, name, date_of_birth, icon, attributes, hass))

component = EntityComponent(_LOGGER, DOMAIN, hass)
await component.async_add_entities(devices)


tasks = [asyncio.create_task(device.update_data()) for device in devices]
await asyncio.wait(tasks)

Expand All @@ -64,7 +91,7 @@ def __init__(self, unique_id, name, date_of_birth, icon, attributes, hass):

if unique_id is not None:
self._unique_id = slugify(unique_id)
else:
else:
self._unique_id = slugify(name)

self._state = None
Expand All @@ -75,10 +102,15 @@ def __init__(self, unique_id, name, date_of_birth, icon, attributes, hass):
self._extra_state_attributes = {
CONF_DATE_OF_BIRTH: str(self._date_of_birth),
}
self._templated_attributes = {}

if len(attributes) > 0 and attributes is not None:
for k,v in attributes.items():
self._extra_state_attributes[k] = v
for k, v in attributes.items():
if is_template_string(v):
_LOGGER.info(f'{v} is a template and will be evaluated at runtime')
self._templated_attributes[k] = Template(template=v, hass=hass)
else:
self._extra_state_attributes[k] = v

@property
def name(self):
Expand All @@ -103,8 +135,16 @@ def icon(self):

@property
def extra_state_attributes(self):
for key, templated_value in self._templated_attributes.items():
value = render_complex(templated_value, variables={"this": self})
self._extra_state_attributes[key] = value

return self._extra_state_attributes

@property
def date_of_birth(self):
return self._date_of_birth

@property
def unit_of_measurement(self):
return 'days'
Expand All @@ -117,25 +157,24 @@ def _get_seconds_until_midnight(self):
one_day_in_seconds = 24 * 60 * 60

now = dt_util.now()
total_seconds_passed_today = (now.hour*60*60) + (now.minute*60) + now.second
total_seconds_passed_today = (now.hour * 60 * 60) + (now.minute * 60) + now.second

return one_day_in_seconds - total_seconds_passed_today

async def update_data(self, *_):
from datetime import date, timedelta
from datetime import date

today = dt_util.start_of_local_day().date()
next_birthday = date(today.year, self._date_of_birth.month, self._date_of_birth.day)

if next_birthday < today:
next_birthday = next_birthday.replace(year=today.year + 1)

days_until_next_birthday = (next_birthday-today).days
days_until_next_birthday = (next_birthday - today).days

age = next_birthday.year - self._date_of_birth.year
self._extra_state_attributes[CONF_AGE_AT_NEXT_BIRTHDAY] = age


self._state = days_until_next_birthday

if days_until_next_birthday == 0:
Expand Down
14 changes: 14 additions & 0 deletions requirements.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# linters such as flake8 and pylint should be pinned, as new releases
# make new things fail. Manually update these pins when pulling in a
# new version

aioresponses==0.7.2
algoliasearch==2.6.2
codecov==2.1.13
coverage>=6.4.4
mypy==0.991
pytest>=7.1.0
pytest-cov>=3.0.0
pytest-mock>=3.10
pytest-homeassistant-custom-component==0.12.20
typing-extensions>=4.6.3
61 changes: 61 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
[coverage:run]
source =
custom_components

[coverage:report]
exclude_lines =
pragma: no cover
raise NotImplemented()
if __name__ == '__main__':
main()
fail_under = 40
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 = " "
# 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 = homeassistant,tests
forced_separate = tests
combine_as_imports = true

[mypy]
python_version = 3.10
ignore_errors = true
follow_imports = silent
ignore_missing_imports = true
warn_incomplete_stub = true
warn_redundant_casts = true
warn_unused_configs = true
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the birthdays component."""
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""pytest fixtures."""
import pytest


@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Enable custom integrations defined in the test dir."""
yield
Loading

0 comments on commit dc1a4e5

Please sign in to comment.