From b2fe564dd248b23c4962494f2e4abb6d2d743cf9 Mon Sep 17 00:00:00 2001 From: Oskar Stenberg <01ste02@gmail.com> Date: Sat, 11 Jan 2025 19:01:38 +0100 Subject: [PATCH 1/2] Change component to allow change in joke length and config of joke interval --- .gitignore | 4 + README.md | 8 +- custom_components/jokes/__init__.py | 176 +++++++++++++++---------- custom_components/jokes/config_flow.py | 85 ++++++++++-- custom_components/jokes/const.py | 17 +++ custom_components/jokes/coordinator.py | 114 ++++++++++++++++ custom_components/jokes/manifest.json | 2 +- custom_components/jokes/sensor.py | 86 +++++++++--- hacs.json | 6 +- info.md | 8 +- 10 files changed, 400 insertions(+), 106 deletions(-) create mode 100644 custom_components/jokes/coordinator.py diff --git a/.gitignore b/.gitignore index b6e4761..cf079ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ # C extensions *.so +# Emacs files +*.*~ +\#*\# + # Distribution / packaging .Python build/ diff --git a/README.md b/README.md index ed1c80e..2370378 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Jokes integration -This is the simplest but somewhat useful and funny integration I could think of. It even doesn't use a single entity, just one state returning a string. +This is the simplest but somewhat useful and funny integration I could think of. -Every Minute, a sensor provides a joke. Done. +A sensor provides a joke at a configurable time. Max joke length can also be configured. ## Shoutout/Kudos @@ -10,6 +10,10 @@ Every Minute, a sensor provides a joke. Done. Thanks for providing the 'Random dad joke' API. They also offer Discord, Alexa and many more integrations :) Really great and fun project. +* https://github.com/msp1974/HAIntegrationExamples + +For tips on how to structure config flows and device info + * Logo used for this integration diff --git a/custom_components/jokes/__init__.py b/custom_components/jokes/__init__.py index e357efa..2846647 100644 --- a/custom_components/jokes/__init__.py +++ b/custom_components/jokes/__init__.py @@ -1,87 +1,129 @@ """Provides random jokes.""" - -from .const import DOMAIN -import aiohttp -import asyncio from datetime import timedelta +import logging +import asyncio +import aiohttp + from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import Platform from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import logging -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_UPDATE_INTERVAL, + CONF_DEVICENAME, + CONF_NAME, + CONF_NUM_TRIES, + CONF_JOKE_LENGTH, + DOMAIN, + DEFAULT_NAME, + DEFAULT_UPDATE_INTERVAL, + DEFAULT_JOKE_LENGTH, + DEFAULT_DEVICENAME, + DEFAULT_RETRIES, +) +from .coordinator import JokeUpdateCoordinator -# def set_joke(hass: HomeAssistant, text: str): -# """Helper function to set the random joke.""" -# _LOGGER.debug("set_joke") -# hass.states.async_set("jokes.random_joke", text) - -def setup(hass: HomeAssistant, config: dict): - """This setup does nothing, we use the async setup.""" - _LOGGER.debug("setup") - return True - -async def async_setup(hass: HomeAssistant, config: dict): - """Setup from configuration.yaml.""" - _LOGGER.debug("async_setup") - - #`config` is the full dict from `configuration.yaml`. - #set_joke(hass, "") - - return True +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Setup from Config Flow Result.""" _LOGGER.debug("async_setup_entry") - + + hass.data.setdefault(DOMAIN, {}) + coordinator = JokeUpdateCoordinator( hass, - _LOGGER, - update_interval=timedelta(seconds=60) + config_entry ) - await coordinator.async_refresh() - - hass.data[DOMAIN] = { - "coordinator": coordinator + + # Perform initial data load from API + await coordinator.async_config_entry_first_refresh() + + if not coordinator.api_connected: + raise ConfigEntryNotReady + + # Add listener to enable reconfiguration + update_listener = config_entry.add_update_listener(_async_update_listener) + + hass.data[DOMAIN][config_entry.entry_id] = { + "coordinator": coordinator, + "update_listener": update_listener, } - - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, entry)) + + # Setup platforms + await hass.config_entries.async_forward_entry_setups(config_entry, [Platform.SENSOR]) + return True -class JokeUpdateCoordinator(DataUpdateCoordinator): - """Update handler.""" - def __init__(self, hass, logger, update_interval=None): - """Initialize global data updater.""" - logger.debug("__init__") +async def _async_update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entry.""" + # From home assistant developer documentation + _LOGGER.debug("Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False - super().__init__( - hass, - logger, - name=DOMAIN, - update_interval=update_interval, - update_method=self._async_update_data, + if config_entry.version == 1: + new_data = {**config_entry.data} + if config_entry.minor_version < 1: + new_data[CONF_NAME] = DEFAULT_NAME + new_data[CONF_UPDATE_INTERVAL] = DEFAULT_UPDATE_INTERVAL + new_data[CONF_JOKE_LENGTH] = DEFAULT_JOKE_LENGTH + + if config_entry.minor_version < 3: + new_data[CONF_DEVICENAME] = DEFAULT_DEVICENAME + new_data[CONF_NUM_TRIES] = DEFAULT_RETRIES + + hass.config_entries.async_update_entry( + config_entry, + data=new_data, + minor_version=3, + version=1 ) - - async def _async_update_data(self): - """Fetch a random joke.""" - self.logger.debug("_async_update_data") - - #get a random joke (finally) - try: - headers = { - 'Accept': 'application/json', - 'User-Agent': 'Jokes custom integration for Home Assistant (https://github.com/LaggAt/ha-jokes)' - } - async with aiohttp.ClientSession() as session: - async with session.get('https://icanhazdadjoke.com/', headers=headers) as resp: - if resp.status == 200: - json = await resp.json() - #set_joke(self._hass, json["joke"]) - # return the joke object - return json - else: - raise UpdateFailed(f"Response status code: {resp.status}") - except Exception as ex: - raise UpdateFailed from ex + + _LOGGER.debug("Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version) + + return True + + +async def async_remove_config_entry_device( + _hass: HomeAssistant, _config_entry: ConfigEntry, _device_entry: DeviceEntry +) -> bool: + """Delete device if selected from UI.""" + # Adding this function shows the delete device option in the UI. + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + # This is called when you remove your integration or shutdown HA. + + # Remove the config options update listener + hass.data[DOMAIN][config_entry.entry_id]["update_listener"]() + + # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, Platform.SENSOR + ) + + # Remove the config entry from the hass data object. + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok diff --git a/custom_components/jokes/config_flow.py b/custom_components/jokes/config_flow.py index e80708f..0b1028c 100644 --- a/custom_components/jokes/config_flow.py +++ b/custom_components/jokes/config_flow.py @@ -1,18 +1,81 @@ """Config flow for Jokes integration.""" +import logging +from typing import Any +import voluptuous as vol -from .const import DOMAIN -from homeassistant import config_entries +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + CONN_CLASS_CLOUD_POLL +) -@config_entries.HANDLERS.register(DOMAIN) -class JokesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +from .const import ( + CONF_DEVICENAME, + CONF_NAME, + CONF_UPDATE_INTERVAL, + CONF_JOKE_LENGTH, + CONF_NUM_TRIES, + DOMAIN, + DEFAULT_DEVICENAME, + DEFAULT_NAME, + DEFAULT_JOKE_LENGTH, + DEFAULT_UPDATE_INTERVAL, + DEFAULT_RETRIES, +) + +_LOGGER = logging.getLogger(__name__) + +class JokesFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Jokes.""" - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + VERSION = 1.4 + CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL - async def async_step_user(self, user_input=None): + def get_data_schema(self, + name: str = None, + interval: int | None = None, + length: int | None = None, + retries: int | None = None, + devicename: str | None = None + ): + return vol.Schema({ + vol.Optional( + CONF_NAME, + default=name if name else DEFAULT_NAME, + ): str, + vol.Optional( + CONF_UPDATE_INTERVAL, + default=interval if interval else DEFAULT_UPDATE_INTERVAL, + description="Time between fetching new jokes in seconds.", + ): int, + vol.Optional( + CONF_JOKE_LENGTH, + default=length if length else DEFAULT_JOKE_LENGTH, + description="Do not allow jokes longer than this.", + ): int, + vol.Optional( + CONF_NUM_TRIES, + default=retries if retries else DEFAULT_RETRIES, + description="Number of retries at fetching a joke,", + ): int, + vol.Optional( + CONF_DEVICENAME, + default=devicename if devicename else DEFAULT_DEVICENAME, + description="What to call the devices for this integration.", + ): str, + }) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Show config Form step.""" - return self.async_create_entry( - title="jokes config", - data={}, - ) \ No newline at end of file + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=self.get_data_schema(), + ) + + title = f"Random Joke - {user_input[CONF_UPDATE_INTERVAL]}s" + await self.async_set_unique_id(f"random_joke_{user_input[CONF_UPDATE_INTERVAL]}") + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=user_input) diff --git a/custom_components/jokes/const.py b/custom_components/jokes/const.py index 56ba4b4..689b09d 100644 --- a/custom_components/jokes/const.py +++ b/custom_components/jokes/const.py @@ -1,3 +1,20 @@ """Jokes constants.""" DOMAIN = "jokes" + +DEFAULT_UPDATE_INTERVAL = 60 +DEFAULT_JOKE_LENGTH = 255 +DEFAULT_NAME = "Random Joke" +DEFAULT_DEVICENAME = "random_joke" +DEFAULT_RETRIES = 10 + +MIN_UPDATE_INTERVAL = 15 +MIN_RETRIES = 1 + +MAX_STATE_JOKE_LENGTH = 255 + +CONF_UPDATE_INTERVAL = "Update interval" +CONF_DEVICENAME = "Device base name" +CONF_NAME = "Name" +CONF_NUM_TRIES = "Number of retries" +CONF_JOKE_LENGTH = "Joke max length" diff --git a/custom_components/jokes/coordinator.py b/custom_components/jokes/coordinator.py new file mode 100644 index 0000000..80d6819 --- /dev/null +++ b/custom_components/jokes/coordinator.py @@ -0,0 +1,114 @@ +"""Provides the coordinator for the joke integration.""" +import logging +from datetime import timedelta +import aiohttp +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import ( + CONF_DEVICENAME, + CONF_NAME, + CONF_JOKE_LENGTH, + CONF_UPDATE_INTERVAL, + CONF_NUM_TRIES, + DEFAULT_NAME, + DEFAULT_JOKE_LENGTH, + DEFAULT_UPDATE_INTERVAL, + DEFAULT_RETRIES, + DOMAIN, + MIN_UPDATE_INTERVAL, + MIN_RETRIES, +) + +HEADERS = { + 'Accept': 'application/json', + 'User-Agent': 'Jokes custom integration for Home Assistant (https://github.com/LaggAt/ha-jokes)' +} + +_LOGGER = logging.getLogger(__name__) + +class JokeUpdateCoordinator(DataUpdateCoordinator): + """Update handler.""" + + def __init__(self, hass, config_entry): + """Initialize global data updater.""" + _LOGGER.debug("__init__") + + self.api_connected = False + + self.device_friendly_name = config_entry.data.get( + CONF_NAME, + DEFAULT_NAME + ) + + self.joke_length = config_entry.data.get( + CONF_JOKE_LENGTH, + DEFAULT_JOKE_LENGTH + ) + + self.uid = config_entry.unique_id + # Get the update interval and ensure that it is not too small + self.update_interval = timedelta( + seconds=max( + config_entry.data.get( + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL + ), + MIN_UPDATE_INTERVAL + ) + ) + + self.retries = max( + config_entry.data.get( + CONF_NUM_TRIES, + DEFAULT_RETRIES + ), + MIN_RETRIES + ) + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} ({self.uid})", + update_interval=self.update_interval, + update_method=self._async_update_data, + )60 + + + async def _async_update_data(self): + """Fetch a random joke.""" + _LOGGER.debug("_async_update_data") + + try: + device = { + "id": f"{self.uid}", + "uid": f"{self.uid}_device", + "name": self.device_friendly_name, + "state": await self._async_get_data(), + } + + except Exception as err: + _LOGGER.error(err) + raise UpdateFailed from err + + return device + + + async def _async_get_data(self): + async with aiohttp.ClientSession() as session: + for _ in range(0, self.retries): + async with session.get( + 'https://icanhazdadjoke.com/', + headers=HEADERS + ) as resp: + if resp.status == 200: + json = await resp.json() + + # Ensure that joke exists and is not too long + if "joke" not in json or len(json["joke"]) > self.joke_length: + continue + + # Signal that we got a connection, + # so we know that the integration should not give up + self.api_connected = True + return json + raise UpdateFailed(f"Response status code: {resp.status}") + raise UpdateFailed(f"Could not get joke after {self.retries} tries") diff --git a/custom_components/jokes/manifest.json b/custom_components/jokes/manifest.json index 2eb3581..bc0ba15 100644 --- a/custom_components/jokes/manifest.json +++ b/custom_components/jokes/manifest.json @@ -2,7 +2,7 @@ "codeowners": ["@LaggAt"], "domain": "jokes", "name": "Jokes", - "version": "0.0.1", + "version": "0.0.2", "config_flow": true, "documentation": "https://github.com/LaggAt/ha-jokes/blob/master/README.md", "issue_tracker": "https://github.com/LaggAt/ha-jokes/issues", diff --git a/custom_components/jokes/sensor.py b/custom_components/jokes/sensor.py index 0c60ffc..2165fe6 100644 --- a/custom_components/jokes/sensor.py +++ b/custom_components/jokes/sensor.py @@ -1,41 +1,91 @@ """Joke Sensor.""" +import logging -from .const import DOMAIN -from homeassistant import core -from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator +from homeassistant.core import callback, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback -async def async_setup_platform( - hass: core.HomeAssistant, config, async_add_entities, discovery_info=None +from .const import DOMAIN, MAX_STATE_JOKE_LENGTH, CONF_UPDATE_INTERVAL +from .coordinator import JokeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ): - """Setup the sensor platform.""" - coordinator = hass.data[DOMAIN]["coordinator"] - async_add_entities([JokeEntity(coordinator)], True) + """Setup the sensors.""" + coordinator = hass.data[DOMAIN][ + config_entry.entry_id + ]["coordinator"] + + async_add_entities([JokeEntity(coordinator, coordinator.data)]) + -class JokeEntity(CoordinatorEntity): +class JokeEntity(CoordinatorEntity, SensorEntity): """Dummy entity to trigger updates.""" _attr_icon = "mdi:emoticon-excited-outline" - def __init__(self, coordinator: DataUpdateCoordinator): + def __init__(self, coordinator: JokeUpdateCoordinator, device): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) + self.device = device + self.device_id = device["id"] + + + @callback + def _handle_coordinator_update(self) -> None: + """Update sensor with latest data from coordinator.""" + # This method is called by DataUpdateCoordinator when a successful update runs. + self.device = self.coordinator.data + _LOGGER.debug("Device: %s", self.device) + self.async_write_ha_state() + + @property - def entity_id(self): - """Return the entity id of the sensor.""" - return "sensor.random_joke" + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + name=f"Random Joke - {self.coordinator.update_interval}", + manufacturer="LaggAt", + model="Random Joke", + sw_version="0.0.1", + identifiers={ + ( + DOMAIN, + f"{self.device_id}", + ) + }, + ) + @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" - return "Random Joke" + return self.device["name"] + + + @property + def unique_id(self) -> str: + """Return unique id.""" + return f"{DOMAIN}-{self.device['uid']}" + @property def state(self): """Return the state of the sensor.""" - return self.coordinator.data["joke"] + # Cut off joke at joke_length chars... Full joke exists in extra attributes + cutoff = min(MAX_STATE_JOKE_LENGTH, self.coordinator.joke_length) + return self.device["state"]["joke"][:cutoff] + @property def extra_state_attributes(self): - return self.coordinator.data - \ No newline at end of file + """Return the extra attributes and full state of the sensor.""" + return self.device["state"] diff --git a/hacs.json b/hacs.json index 8c73e22..7abf456 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "jokes", - "hacs": "0.0.1", - "homeassistant": "2022.11.1" -} \ No newline at end of file + "hacs": "0.0.3", + "homeassistant": "2024.12.4" +} diff --git a/info.md b/info.md index 3519008..15690e7 100644 --- a/info.md +++ b/info.md @@ -2,7 +2,7 @@ ![Project Maintenance][maintenance-shield] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] -_Example Component providing fresh jokes every minute._ +_Example Component providing fresh jokes at a configurable interval._ **This component will set up the following platforms.** @@ -20,9 +20,9 @@ Platform | Description {% endif %} -## No Configuration +## Configuration -Works out of the box. +Works out of the box with default values, and the Update interval can be changed from the config flow. ## Sponsor @@ -43,4 +43,4 @@ Thank you! [license-shield]: https://img.shields.io/github/license/LaggAt/ha-jokes [maintenance-shield]: https://img.shields.io/badge/maintainer-Florian%20Lagg-blue.svg?style=for-the-badge [releases-shield]: https://img.shields.io/github/release/custom-components/ha-jokes.svg?style=for-the-badge -[releases]: https://github.com/custom-components/ha-jokes/releases \ No newline at end of file +[releases]: https://github.com/custom-components/ha-jokes/releases From d1b28e8d35c6250773029189e6e920be8415b1f2 Mon Sep 17 00:00:00 2001 From: Oskar Stenberg <01ste02@gmail.com> Date: Sat, 11 Jan 2025 19:32:59 +0100 Subject: [PATCH 2/2] Fixed stray number in coordinator.. --- custom_components/jokes/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/jokes/coordinator.py b/custom_components/jokes/coordinator.py index 80d6819..b370e02 100644 --- a/custom_components/jokes/coordinator.py +++ b/custom_components/jokes/coordinator.py @@ -70,7 +70,7 @@ def __init__(self, hass, config_entry): name=f"{DOMAIN} ({self.uid})", update_interval=self.update_interval, update_method=self._async_update_data, - )60 + ) async def _async_update_data(self):