├── tests ├── __init__.py ├── conftest.py └── test_init.py ├── custom_components ├── __init__.py └── birthdays │ ├── const.py │ ├── translations │ ├── en.json │ ├── nl.json │ └── sv.json │ ├── manifest.json │ ├── calendar.py │ └── __init__.py ├── .gitignore ├── requirements.test.txt ├── .github └── workflows │ └── pre-commit.yml ├── .pre-commit-config.yaml ├── setup.cfg └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the birthdays component.""" 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """ Custom components module""" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | .history 3 | .idea 4 | .mypy_cache 5 | __pycache__/ 6 | .coverage -------------------------------------------------------------------------------- /custom_components/birthdays/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = 'birthdays' 2 | DOMAIN_FRIENDLY_NAME = 'Birthdays' 3 | -------------------------------------------------------------------------------- /custom_components/birthdays/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "unit_of_measurement": { 4 | "single_day": "day", 5 | "multiple_days": "days" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /custom_components/birthdays/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "unit_of_measurement": { 4 | "single_day": "dag", 5 | "multiple_days": "dagen" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /custom_components/birthdays/translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "unit_of_measurement": { 4 | "single_day": "dag", 5 | "multiple_days": "dagar" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /custom_components/birthdays/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "birthdays", 3 | "name": "Birthdays", 4 | "documentation": "https://github.com/Miicroo/ha-birthdays", 5 | "dependencies": [], 6 | "codeowners": ["@Miicroo"], 7 | "requirements": [], 8 | "version": "1.3.0" 9 | } 10 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | # linters such as flake8 and pylint should be pinned, as new releases 2 | # make new things fail. Manually update these pins when pulling in a 3 | # new version 4 | 5 | aioresponses==0.7.2 6 | algoliasearch==2.6.2 7 | codecov==2.1.13 8 | coverage>=6.4.4 9 | mypy==0.991 10 | pytest>=7.1.0 11 | pytest-cov>=3.0.0 12 | pytest-mock>=3.10 13 | pytest-homeassistant-custom-component==0.12.20 14 | typing-extensions>=4.6.3 15 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | pre-commit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-python@v4 13 | with: 14 | python-version: '3.10-dev' 15 | - uses: pre-commit/action@v3.0.0 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r requirements.test.txt 20 | - name: Run pytest 21 | run: | 22 | pytest 23 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """pytest fixtures.""" 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from custom_components.birthdays import Translation 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def auto_enable_custom_integrations(enable_custom_integrations): 11 | """Enable custom integrations defined in the test dir.""" 12 | yield 13 | 14 | 15 | @pytest.fixture(autouse=True) 16 | def mock_translations(): 17 | """Mock translations globally.""" 18 | translation = Translation(single_day_unit="day", multiple_days_unit="days") 19 | with patch( 20 | "custom_components.birthdays._get_translation", 21 | return_value=translation, 22 | autospec=True, 23 | ) as m: 24 | yield m 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.2.2 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py310-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 22.10.0 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v2.2.2 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --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 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://github.com/PyCQA/flake8 25 | rev: 5.0.4 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.5.0 30 | - pydocstyle==5.0.2 31 | files: ^(homeassistant|script|tests)/.+\.py$ 32 | - repo: https://github.com/PyCQA/isort 33 | rev: 5.12.0 34 | hooks: 35 | - id: isort 36 | - repo: https://github.com/pre-commit/pre-commit-hooks 37 | rev: v4.3.0 38 | hooks: 39 | - id: check-executables-have-shebangs 40 | stages: [manual] 41 | - id: check-json 42 | - repo: https://github.com/pre-commit/mirrors-mypy 43 | rev: v0.991 44 | hooks: 45 | - id: mypy 46 | args: 47 | - --pretty 48 | - --show-error-codes 49 | - --show-error-context 50 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | fail_under = 40 12 | show_missing = true 13 | 14 | [tool:pytest] 15 | testpaths = tests 16 | norecursedirs = .git 17 | addopts = 18 | --strict 19 | --cov=custom_components 20 | 21 | [flake8] 22 | # https://github.com/ambv/black#line-length 23 | max-line-length = 88 24 | # E501: line too long 25 | # W503: Line break occurred before a binary operator 26 | # E203: Whitespace before ':' 27 | # D202 No blank lines allowed after function docstring 28 | # W504 line break after binary operator 29 | ignore = 30 | E501, 31 | W503, 32 | E203, 33 | D202, 34 | W504 35 | 36 | [isort] 37 | # https://github.com/timothycrosley/isort 38 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 39 | # splits long import on multiple lines indented by 4 spaces 40 | multi_line_output = 3 41 | include_trailing_comma=True 42 | force_grid_wrap=0 43 | use_parentheses=True 44 | line_length=88 45 | indent = " " 46 | # will group `import x` and `from x import` of the same module. 47 | force_sort_within_sections = true 48 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 49 | default_section = THIRDPARTY 50 | known_first_party = homeassistant,tests 51 | forced_separate = tests 52 | combine_as_imports = true 53 | 54 | [mypy] 55 | python_version = 3.10 56 | ignore_errors = true 57 | follow_imports = silent 58 | ignore_missing_imports = true 59 | warn_incomplete_stub = true 60 | warn_redundant_casts = true 61 | warn_unused_configs = true 62 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test component setup.""" 2 | from custom_components.birthdays import ( 3 | CONF_ATTRIBUTES, 4 | CONF_BIRTHDAYS, 5 | CONF_GLOBAL_CONFIG, 6 | DOMAIN, 7 | ) 8 | from homeassistant.setup import async_setup_component 9 | 10 | 11 | async def test_async_setup__old_config_0_birthday_is_not_ok(hass): 12 | """Cannot have 0 birthdays configured in old config.""" 13 | config = {DOMAIN: []} 14 | await _test_setup(hass, config, False) 15 | 16 | 17 | async def test_async_setup__old_config_1_birthday_is_ok(hass): 18 | """1 birthday is OK in old config.""" 19 | config = {DOMAIN: [{"name": "HomeAssistant", "date_of_birth": "2013-09-17"}]} 20 | await _test_setup(hass, config, True) 21 | 22 | 23 | async def test_async_setup__new_config_0_birthday_is_not_ok(hass): 24 | """Cannot have 0 birthdays configured in old config.""" 25 | config = {DOMAIN: {CONF_BIRTHDAYS: []}} 26 | await _test_setup(hass, config, False) 27 | 28 | 29 | async def test_async_setup__new_config_1_birthday_is_ok(hass): 30 | """1 birthday is OK in new config.""" 31 | config = { 32 | DOMAIN: { 33 | CONF_BIRTHDAYS: [{"name": "HomeAssistant", "date_of_birth": "2013-09-17"}] 34 | } 35 | } 36 | await _test_setup(hass, config, True) 37 | 38 | 39 | async def test_async_setup__new_config_has_global_attributes(hass): 40 | """Global attributes are allowed in schema.""" 41 | name = "HomeAssistant" 42 | config = { 43 | DOMAIN: { 44 | CONF_BIRTHDAYS: [{"name": name, "date_of_birth": "2013-09-17"}], 45 | CONF_GLOBAL_CONFIG: {CONF_ATTRIBUTES: {"message": "Hello World!"}}, 46 | } 47 | } 48 | 49 | await _test_setup(hass, config, True) 50 | 51 | 52 | async def _test_setup(hass, config: dict, expected_result: bool): 53 | assert await async_setup_component(hass, DOMAIN, config) is expected_result 54 | -------------------------------------------------------------------------------- /custom_components/birthdays/calendar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date, datetime, timedelta 4 | import logging 5 | 6 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 7 | from homeassistant.core import HomeAssistant 8 | 9 | from .const import DOMAIN, DOMAIN_FRIENDLY_NAME 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_platform(hass: HomeAssistant, config, async_add_entities, discovery_info=None): 15 | """Set up the calendar platform.""" 16 | if discovery_info is None: 17 | return 18 | 19 | birthdays = hass.states.async_all(DOMAIN) 20 | birthday_events = [ 21 | BirthdayEvent( 22 | birthday=datetime.strptime(b.attributes['date_of_birth'], '%Y-%m-%d').date(), 23 | name=b.attributes['friendly_name'], 24 | days_to_birthday=b.state 25 | ) for b in birthdays] 26 | 27 | if len(birthday_events) > 0: 28 | async_add_entities([BirthdayCalendarEntity(birthday_events)], update_before_add=True) 29 | 30 | return True 31 | 32 | 33 | class BirthdayCalendarEntity(CalendarEntity): 34 | """Birthday calendar entity.""" 35 | 36 | def __init__(self, events: [BirthdayEvent]) -> None: 37 | self._events = events 38 | self._attr_name = DOMAIN_FRIENDLY_NAME 39 | 40 | @property 41 | def unique_id(self) -> str: 42 | """Return a unique ID.""" 43 | return f'calendar.{DOMAIN}' 44 | 45 | @property 46 | def event(self) -> CalendarEvent | None: 47 | """Return the next upcoming event.""" 48 | sorted_events: list[BirthdayEvent] = sorted(self._events, key=lambda e: e.days_to_birthday) 49 | for event in sorted_events: 50 | if not event.has_passed(): 51 | return event.to_hass_calendar_event() 52 | 53 | return None 54 | 55 | async def async_get_events( 56 | self, 57 | hass: HomeAssistant, 58 | start_date: datetime, 59 | end_date: datetime, 60 | ) -> list[CalendarEvent]: 61 | return [event.to_hass_calendar_event() 62 | for event in self._events 63 | if self.in_range(str(event.date), start_date.date(), end_date.date()) 64 | ] 65 | 66 | @staticmethod 67 | def in_range(isodate: str, start: date, end: date) -> bool: 68 | return start <= date.fromisoformat(isodate) <= end 69 | 70 | 71 | class BirthdayEvent: 72 | def __init__(self, birthday: date, name: str, days_to_birthday: int): 73 | birth_year = birthday.year 74 | current_year = datetime.now().year 75 | self.date = birthday.replace(year=current_year) 76 | self.description = f'{name}, {current_year - birth_year}' 77 | self.days_to_birthday = days_to_birthday 78 | 79 | def to_hass_calendar_event(self) -> CalendarEvent: 80 | end = self.date + timedelta(days=1) 81 | return CalendarEvent(start=self.date, end=end, summary=str(self.description), description=str(self.description)) 82 | 83 | def has_passed(self) -> bool: 84 | return self.date < date.today() 85 | -------------------------------------------------------------------------------- /custom_components/birthdays/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | import logging 4 | 5 | import voluptuous as vol 6 | 7 | from homeassistant.const import Platform 8 | import homeassistant.helpers.config_validation as cv 9 | from homeassistant.helpers.discovery import async_load_platform 10 | from homeassistant.helpers.entity import Entity 11 | from homeassistant.helpers.entity_component import EntityComponent 12 | from homeassistant.helpers.event import async_call_later 13 | from homeassistant.helpers.template import Template, is_template_string, render_complex 14 | from homeassistant.helpers.translation import async_get_translations 15 | from homeassistant.util import dt as dt_util, slugify 16 | 17 | from .const import DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | CONF_UNIQUE_ID = 'unique_id' 22 | CONF_NAME = 'name' 23 | CONF_DATE_OF_BIRTH = 'date_of_birth' 24 | CONF_ICON = 'icon' 25 | CONF_ATTRIBUTES = 'attributes' 26 | CONF_GLOBAL_CONFIG = 'config' 27 | CONF_BIRTHDAYS = 'birthdays' 28 | CONF_AGE_AT_NEXT_BIRTHDAY = 'age_at_next_birthday' 29 | 30 | BIRTHDAY_CONFIG_SCHEMA = vol.Schema({ 31 | vol.Optional(CONF_UNIQUE_ID): cv.string, 32 | vol.Required(CONF_NAME): cv.string, 33 | vol.Required(CONF_DATE_OF_BIRTH): cv.date, 34 | vol.Optional(CONF_ICON, default='mdi:cake'): cv.string, 35 | vol.Optional(CONF_ATTRIBUTES, default={}): vol.Schema({cv.string: cv.string}), 36 | }) 37 | 38 | GLOBAL_CONFIG_SCHEMA = vol.Schema({ 39 | vol.Optional(CONF_ATTRIBUTES, default={}): vol.Schema({cv.string: cv.string}), 40 | }) 41 | 42 | # Old schema (list of birthday configurations) 43 | OLD_CONFIG_SCHEMA = vol.Schema({ 44 | DOMAIN: vol.All(cv.ensure_list, [BIRTHDAY_CONFIG_SCHEMA]) 45 | }, extra=vol.ALLOW_EXTRA) 46 | 47 | # New schema (supports both global and birthday configs) 48 | NEW_CONFIG_SCHEMA = vol.Schema({ 49 | DOMAIN: { 50 | CONF_BIRTHDAYS: vol.All(cv.ensure_list, [BIRTHDAY_CONFIG_SCHEMA]), 51 | vol.Optional(CONF_GLOBAL_CONFIG, default={}): GLOBAL_CONFIG_SCHEMA 52 | } 53 | }, extra=vol.ALLOW_EXTRA) 54 | 55 | # Use vol.Any() to support both old and new schemas 56 | CONFIG_SCHEMA = vol.Schema(vol.Any( 57 | OLD_CONFIG_SCHEMA, 58 | NEW_CONFIG_SCHEMA 59 | ), extra=vol.ALLOW_EXTRA) 60 | 61 | 62 | @dataclass 63 | class Translation: 64 | single_day_unit: str 65 | multiple_days_unit: str 66 | 67 | 68 | async def async_setup(hass, config): 69 | devices = [] 70 | 71 | is_new_config = isinstance(config[DOMAIN], dict) and config[DOMAIN].get(CONF_BIRTHDAYS) is not None 72 | birthdays = config[DOMAIN][CONF_BIRTHDAYS] if is_new_config else config[DOMAIN] 73 | translation = await _get_translation(hass) 74 | 75 | for birthday_data in birthdays: 76 | unique_id = birthday_data.get(CONF_UNIQUE_ID) 77 | name = birthday_data[CONF_NAME] 78 | date_of_birth = birthday_data[CONF_DATE_OF_BIRTH] 79 | icon = birthday_data[CONF_ICON] 80 | attributes = birthday_data[CONF_ATTRIBUTES] 81 | if is_new_config: 82 | global_config = config[DOMAIN][CONF_GLOBAL_CONFIG] # Empty dict or has attributes 83 | global_attributes = global_config.get(CONF_ATTRIBUTES) or {} 84 | attributes = dict(global_attributes, 85 | **attributes) # Add global_attributes but let local attributes be on top 86 | 87 | devices.append(BirthdayEntity(unique_id, name, date_of_birth, icon, attributes, translation, hass)) 88 | 89 | # Set up component 90 | component = EntityComponent(_LOGGER, DOMAIN, hass) 91 | await component.async_add_entities(devices) 92 | 93 | # Update state 94 | tasks = [asyncio.create_task(device.update_data()) for device in devices] 95 | await asyncio.wait(tasks) 96 | 97 | _LOGGER.debug(devices) 98 | hass.async_create_task(async_load_platform(hass, Platform.CALENDAR, DOMAIN, {}, config)) 99 | 100 | return True 101 | 102 | 103 | async def _get_translation(hass) -> Translation: 104 | """Fetch the translated units of measurement and update each sensor.""" 105 | category = "config" 106 | translations = await async_get_translations(hass, 107 | language=hass.config.language, 108 | category=category, 109 | integrations=[DOMAIN]) 110 | 111 | base_key = f'component.{DOMAIN}.{category}.unit_of_measurement' 112 | 113 | single_day_unit = translations.get(f'{base_key}.single_day', 'day') 114 | multiple_days_unit = translations.get(f'{base_key}.multiple_days', 'days') 115 | 116 | return Translation(single_day_unit=single_day_unit, multiple_days_unit=multiple_days_unit) 117 | 118 | 119 | class BirthdayEntity(Entity): 120 | 121 | def __init__(self, unique_id, name, date_of_birth, icon, attributes, translation, hass): 122 | self._name = name 123 | 124 | if unique_id is not None: 125 | self._unique_id = slugify(unique_id) 126 | else: 127 | self._unique_id = slugify(name) 128 | 129 | self._state = None 130 | self._icon = icon 131 | self._date_of_birth = date_of_birth 132 | self.hass = hass 133 | 134 | self._extra_state_attributes = { 135 | CONF_DATE_OF_BIRTH: str(self._date_of_birth), 136 | } 137 | self._templated_attributes = {} 138 | 139 | if len(attributes) > 0 and attributes is not None: 140 | for k, v in attributes.items(): 141 | if is_template_string(v): 142 | _LOGGER.info(f'{v} is a template and will be evaluated at runtime') 143 | self._templated_attributes[k] = Template(template=v, hass=hass) 144 | else: 145 | self._extra_state_attributes[k] = v 146 | 147 | self._translation: Translation = translation 148 | 149 | @property 150 | def name(self): 151 | return self._name 152 | 153 | @property 154 | def unique_id(self): 155 | return self._unique_id 156 | 157 | @property 158 | def state(self): 159 | return self._state 160 | 161 | @property 162 | def should_poll(self): 163 | # Do not poll, instead we trigger an asynchronous update every day at midnight 164 | return False 165 | 166 | @property 167 | def icon(self): 168 | return self._icon 169 | 170 | @property 171 | def extra_state_attributes(self): 172 | for key, templated_value in self._templated_attributes.items(): 173 | value = render_complex(templated_value, variables={"this": self}) 174 | self._extra_state_attributes[key] = value 175 | 176 | return self._extra_state_attributes 177 | 178 | @property 179 | def date_of_birth(self): 180 | return self._date_of_birth 181 | 182 | @property 183 | def unit_of_measurement(self): 184 | return self._translation.single_day_unit \ 185 | if self._state is not None and self._state == 1 \ 186 | else self._translation.multiple_days_unit 187 | 188 | @property 189 | def hidden(self): 190 | return self._state is None 191 | 192 | @staticmethod 193 | def _get_seconds_until_midnight(): 194 | one_day_in_seconds = 24 * 60 * 60 195 | 196 | now = dt_util.now() 197 | total_seconds_passed_today = (now.hour * 60 * 60) + (now.minute * 60) + now.second 198 | 199 | return one_day_in_seconds - total_seconds_passed_today 200 | 201 | async def update_data(self, *_): 202 | from datetime import date 203 | 204 | today = dt_util.start_of_local_day().date() 205 | next_birthday = date(today.year, self._date_of_birth.month, self._date_of_birth.day) 206 | 207 | if next_birthday < today: 208 | next_birthday = next_birthday.replace(year=today.year + 1) 209 | 210 | days_until_next_birthday = (next_birthday - today).days 211 | 212 | age = next_birthday.year - self._date_of_birth.year 213 | self._extra_state_attributes[CONF_AGE_AT_NEXT_BIRTHDAY] = age 214 | 215 | self._state = days_until_next_birthday 216 | 217 | if days_until_next_birthday == 0: 218 | # Fire event if birthday is today 219 | self.hass.bus.async_fire(event_type='birthday', event_data={'name': self._name, 'age': age}) 220 | 221 | self.async_write_ha_state() 222 | async_call_later(self.hass, self._get_seconds_until_midnight(), self.update_data) 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Birthdays 2 | This is a HomeAssistant component for tracking birthdays, where the state of each birthday is equal to how many days are left. All birthdays are updated at midnight. 3 | 4 | ## Installation 5 | 6 | ### HACS (recommended) 7 | 1. Go to integrations 8 | 2. Press the dotted menu in the top right corner 9 | 3. Choose custom repositories 10 | 4. Add the URL to this repository 11 | 5. Choose category `Integration` 12 | 6. Click add 13 | 14 | ### Manual 15 | 1. In your homeassistant config directory, create a new directory. The path should look like this: **my-ha-config-dir/custom_components 16 | 2. Copy the contents of /custom_components in this git-repo to your newly created directory in HA 17 | 18 | ## Set up 19 | Set up the component: 20 | ```yaml 21 | # Example configuration.yaml entry 22 | birthdays: 23 | - name: 'Frodo Baggins' 24 | date_of_birth: 1921-09-22 25 | - name: 'Bilbo Baggins' 26 | date_of_birth: 1843-09-22 27 | - name: Elvis 28 | date_of_birth: 1935-01-08 29 | icon: 'mdi:music' 30 | ``` 31 | 32 | You can also add a custom `unique_id` and attributes to each birthday, for instance to add an icon or other metadata. 33 | ```yaml 34 | - unique_id: bond_james_bond 35 | name: James Bond 36 | date_of_birth: 1920-05-25 37 | icon: 'mdi:pistol' 38 | attributes: 39 | occupation: "Agent" 40 | license_to_kill: "Yes" 41 | - unique_id: einstein 42 | name: 'Albert Einstein' 43 | date_of_birth: 1879-03-14 44 | icon: 'mdi:lightbulb-on' 45 | attributes: 46 | occupation: 'Theoretical physicist' 47 | iq: 'Genius level' 48 | sense_of_humor: 'Einsteinian' 49 | ``` 50 | Restart homeassistant 51 | 52 | ## Entities 53 | All entities that do not have a specified `unique_id` are exposed using the format `birthdays.{name}`. Any character that does not fit the pattern `a-z`, `A-Z`, `0-9`, or `_` will be changed. For instance `Frodo Baggins` will get entity_id `frodo_baggins`, and Swedish names like [`Sven-Göran Eriksson`](https://sv.wikipedia.org/wiki/Sven-G%C3%B6ran_Eriksson) will get entity_id `sven_goran_eriksson`. 54 | 55 | ## Custom attributes 56 | You can add a unique id and custom attributes to each birthday, for instance to add an icon or other metadata. 57 | 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. 58 | Fetching the attributes can be done using `state_attr` in a template, for instance `{{ state_attr('birthdays.einstein', 'occupation') }}` will return `Theoretical physicist`. 59 | 60 | ### Templated attributes 61 | Attributes to an entity can also be a template. To do calculations based on data from the entity, use the `this`-keyword. 62 | Be aware that if a template that cannot be correctly parsed it can lead to the entity not being loaded, 63 | so if your entity is suddenly gone after adding a templated attribute, please check the logs. 64 | 65 | Example calculating age in number of days: 66 | ```yaml 67 | birthdays: 68 | - name: 'Frodo Baggins' 69 | date_of_birth: 1921-09-22 70 | attributes: 71 | days_since_birth: '{{ ((as_timestamp(now()) - as_timestamp(this.date_of_birth)) | int /60/1440) | int }}' 72 | ``` 73 | 74 | Properties of `this` that can be used: 75 | * name 76 | * unique_id 77 | * state 78 | * icon 79 | * date_of_birth 80 | * unit_of_measurement 81 | 82 | Note: Don't use `this.extra_state_attributes`, as that might trigger an infinite loop. 83 | 84 | ### Global attributes: 85 | It is possible to add global attributes that will be added to all birthdays. Global attributes work just the same as other attributes, 86 | and can thus also be templated. 87 | 88 | This example will add the attribute `days_since_birth` on all entities: 89 | ```yaml 90 | # Example configuration.yaml entry 91 | birthdays: 92 | config: 93 | attributes: 94 | days_since_birth: '{{ ((as_timestamp(now()) - as_timestamp(this.date_of_birth)) | int /60/1440) | int }}' 95 | birthdays: 96 | - name: 'Frodo Baggins' 97 | date_of_birth: 1921-09-22 98 | - name: 'Bilbo Baggins' 99 | date_of_birth: 1843-09-22 100 | - name: Elvis 101 | date_of_birth: 1935-01-08 102 | icon: 'mdi:music' 103 | ``` 104 | 105 | Note that global attributes will be overridden by entity specific attributes. 106 | 107 | ## Automation 108 | 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. 109 | 110 | Sending a push notification for each birthday (with PushBullet) looks like this: 111 | ```yaml 112 | automation: 113 | trigger: 114 | platform: event 115 | event_type: 'birthday' 116 | action: 117 | service: notify.pushbullet 118 | data_template: 119 | title: 'Birthday!' 120 | message: "{{ trigger.event.data.name }} turns {{ trigger.event.data.age }} today!" 121 | ``` 122 | 123 | If you want to trigger an automation based on a specific name or age, you can use the following: 124 | ```yaml 125 | automation: 126 | trigger: 127 | platform: event 128 | event_type: 'birthday' 129 | event_data: 130 | name: Kalle 131 | # age: 40 132 | action: 133 | service: notify.pushbullet 134 | data_template: 135 | title: 'Birthday!' 136 | message: "{{ trigger.event.data.name }} turns {{ trigger.event.data.age }} today!" 137 | ``` 138 | 139 | If you want to have a notification sent to you at a specific time (instead of midnight), you can use a custom templated sensor and a time trigger. 140 | Create the sensor: 141 | ~~~yaml 142 | template: 143 | - sensor: 144 | - name: "Next birthday" 145 | unique_id: next_birthday 146 | state: > 147 | {%- set ns = namespace(days=365) -%} 148 | {%- for birthday in states.birthdays -%} 149 | {%- set daysLeft = birthday.state | int -%} 150 | {%- if daysLeft < ns.days -%} 151 | {%- set ns.days = daysLeft -%} 152 | {%- endif -%} 153 | {%- endfor -%} 154 | {{ ns.days }} 155 | attributes: 156 | names: > 157 | {%- set ns = namespace(days=365, names=[]) -%} 158 | {%- for birthday in states.birthdays -%} 159 | {%- set daysLeft = birthday.state | int -%} 160 | {%- if daysLeft < ns.days -%} 161 | {%- set ns.days = daysLeft -%} 162 | {%- endif -%} 163 | {%- endfor -%} 164 | {%- for birthday in states.birthdays -%} 165 | {%- set daysLeft = birthday.state | int -%} 166 | {%- if daysLeft == ns.days -%} 167 | {%- set ns.names = ns.names + [birthday.attributes.friendly_name] -%} 168 | {%- endif -%} 169 | {%- endfor -%} 170 | 171 | {{ns.names | join(', ')}} 172 | ages: > 173 | {%- set ns = namespace(days=365, ages=[]) -%} 174 | {%- for birthday in states.birthdays -%} 175 | {%- set daysLeft = birthday.state | int -%} 176 | {%- if daysLeft < ns.days -%} 177 | {%- set ns.days = daysLeft -%} 178 | {%- endif -%} 179 | {%- endfor -%} 180 | {%- for birthday in states.birthdays -%} 181 | {%- set daysLeft = birthday.state | int -%} 182 | {%- if daysLeft == ns.days -%} 183 | {%- set ns.ages = ns.ages + [birthday.attributes.age_at_next_birthday] -%} 184 | {%- endif -%} 185 | {%- endfor -%} 186 | 187 | {{ns.ages | join(', ')}} 188 | birthday_message: > 189 | {%- set ns = namespace(days=365, messages=[]) -%} 190 | {%- for birthday in states.birthdays -%} 191 | {%- set daysLeft = birthday.state | int -%} 192 | {%- if daysLeft < ns.days -%} 193 | {%- set ns.days = daysLeft -%} 194 | {%- endif -%} 195 | {%- endfor -%} 196 | {%- for birthday in states.birthdays -%} 197 | {%- set daysLeft = birthday.state | int -%} 198 | {%- if daysLeft == ns.days -%} 199 | {%- set ns.messages = ns.messages + [birthday.attributes.friendly_name + ' fyller ' + (birthday.attributes.age_at_next_birthday | string) + ' idag!'] -%} 200 | {%- endif -%} 201 | {%- endfor -%} 202 | 203 | {{ns.messages | join('\n')}} 204 | ~~~ 205 | and the automation: 206 | ```yaml 207 | automation: 208 | alias: Happy birthday 209 | trigger: 210 | - platform: time 211 | at: '19:00:00' 212 | condition: 213 | - condition: state 214 | entity_id: sensor.next_birthday 215 | state: '0' 216 | action: 217 | - service: persistent_notification.create 218 | data_template: 219 | title: 'Birthday!' 220 | message: "{{ state_attr('sensor.next_birthday', 'birthday_message') }}" 221 | ``` 222 | 223 | ## Lovelace UI 224 | I use the birthdays as a simple entity list in lovelace, given the above example I use: 225 | ```yaml 226 | # Example use in lovelace 227 | - type: entities 228 | title: Birthdays 229 | show_header_toggle: false 230 | entities: 231 | - birthdays.frodo_baggins 232 | - birthdays.bilbo_baggins 233 | - birthdays.elvis 234 | ``` 235 | 236 | Another possibility is to use the auto-entities card. This allows you to sort the birthdays entered, an example: 237 | ```yaml 238 | # Example using auto-entities 239 | - type: custom:auto-entities 240 | show_empty: false 241 | card: 242 | title: Verjaardagen 243 | type: entities 244 | card_mod: 245 | style: | 246 | #states > * { 247 | margin: 0 !important; 248 | } 249 | filter: 250 | include: 251 | - entity_id: birthdays* 252 | sort: 253 | method: state 254 | ignore_case: false 255 | reverse: false 256 | numeric: true 257 | ``` 258 | 259 | 260 | --------------------------------------------------------------------------------