diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index ca45aa1..e71bc75 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,5 +1,8 @@ name: Release Drafter +permissions: + contents: read + on: push: branches: @@ -9,6 +12,9 @@ on: jobs: update_release_draft: + permissions: + contents: write + pull-requests: write name: Update release draft runs-on: ubuntu-latest steps: @@ -19,6 +25,6 @@ jobs: - name: Create Release uses: release-drafter/release-drafter@v6 with: - disable-releaser: github.ref != 'refs/heads/main' + commitish: refs/heads/main env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/custom_components/state_webhook/__init__.py b/custom_components/state_webhook/__init__.py index 48698e2..3378285 100644 --- a/custom_components/state_webhook/__init__.py +++ b/custom_components/state_webhook/__init__.py @@ -1,3 +1,4 @@ +import asyncio import fnmatch import logging from collections.abc import Mapping @@ -19,14 +20,18 @@ CONF_FILTER_MODE, CONF_PAYLOAD_ATTRIBUTES, CONF_PAYLOAD_OLD_STATE, + CONF_RETRY_LIMIT, CONF_WEBHOOK_AUTH_HEADER, CONF_WEBHOOK_HEADERS, CONF_WEBHOOK_URL, + DEFAULT_RETRY_LIMIT, FilterMode, ) _LOGGER = logging.getLogger(__name__) +RETRY_DELAY = 5 + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _register_webhook(_: Any) -> None: # noqa ANN401 @@ -47,6 +52,7 @@ async def register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: webhook_url = str(entry.options.get(CONF_WEBHOOK_URL)) headers = prepare_headers(entry.options) + retry_limit = int(entry.options.get(CONF_RETRY_LIMIT, DEFAULT_RETRY_LIMIT)) _LOGGER.debug("Start webhook tracking using URL: %s", webhook_url) _LOGGER.debug("Tracking the following entities: %s", entities_to_track) @@ -76,12 +82,17 @@ async def handle_state_change(event: Event[EventStateChangedData]) -> None: new_state.state, ) - await call_webhook( - session, - webhook_url, - headers, - build_payload(entry.options, entity_id, old_state, new_state), - ) + result = False + retry_count = 0 + while not result and retry_count < retry_limit: + result = await call_webhook( + session, + webhook_url, + headers, + build_payload(entry.options, entity_id, old_state, new_state), + ) + retry_count += 1 + await asyncio.sleep(RETRY_DELAY) async_track_state_change_event(hass, entities_to_track, handle_state_change) diff --git a/custom_components/state_webhook/config_flow.py b/custom_components/state_webhook/config_flow.py index 04ddb72..b4b7e10 100644 --- a/custom_components/state_webhook/config_flow.py +++ b/custom_components/state_webhook/config_flow.py @@ -20,6 +20,9 @@ EntitySelectorConfig, LabelSelector, LabelSelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, ObjectSelector, SelectSelector, SelectSelectorConfig, @@ -35,9 +38,11 @@ CONF_ENTITY_LABELS, CONF_PAYLOAD_ATTRIBUTES, CONF_PAYLOAD_OLD_STATE, + CONF_RETRY_LIMIT, CONF_WEBHOOK_AUTH_HEADER, CONF_WEBHOOK_HEADERS, CONF_WEBHOOK_URL, + DEFAULT_RETRY_LIMIT, DOMAIN, FilterMode, ) @@ -47,6 +52,7 @@ vol.Required(CONF_WEBHOOK_URL): TextSelector(), vol.Optional(CONF_WEBHOOK_AUTH_HEADER): TextSelector(), vol.Optional(CONF_WEBHOOK_HEADERS): ObjectSelector(), + vol.Optional(CONF_RETRY_LIMIT, default=DEFAULT_RETRY_LIMIT): NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX)), }, ) diff --git a/custom_components/state_webhook/const.py b/custom_components/state_webhook/const.py index 17ee22f..851bd9c 100644 --- a/custom_components/state_webhook/const.py +++ b/custom_components/state_webhook/const.py @@ -9,10 +9,13 @@ CONF_FILTER_MODE = "filter_mode" CONF_PAYLOAD_ATTRIBUTES = "payload_attributes" CONF_PAYLOAD_OLD_STATE = "payload_old_state" +CONF_RETRY_LIMIT = "retry_limit" CONF_WEBHOOK_URL = "webhook_url" CONF_WEBHOOK_HEADERS = "webhook_headers" CONF_WEBHOOK_AUTH_HEADER = "webhook_auth_header" +DEFAULT_RETRY_LIMIT = 3 + class FilterMode(StrEnum): OR = "or" AND = "and" diff --git a/custom_components/state_webhook/translations/en.json b/custom_components/state_webhook/translations/en.json index 2ad077f..cf21a7c 100644 --- a/custom_components/state_webhook/translations/en.json +++ b/custom_components/state_webhook/translations/en.json @@ -4,11 +4,13 @@ "user": { "data": { "name": "Name", + "retry_limit": "Retry limit", "webhook_url": "Webhook URL", "webhook_auth_header": "Authorization header", "webhook_headers": "Additional headers" }, "data_description": { + "retry_limit": "Number of times to retry sending the webhook, with a delay of 5 seconds between each retry", "webhook_url": "URL where state changes will be sent to using POST request", "webhook_auth_header": "Authorization header to be sent with the request", "webhook_headers": "Additional headers to be sent. Format: key1: value1 on each line" @@ -48,11 +50,13 @@ "webhook": { "data": { "name": "Name", + "retry_limit": "Retry limit", "webhook_url": "Webhook URL", "webhook_auth_header": "Authorization header", "webhook_headers": "Additional headers" }, "data_description": { + "retry_limit": "Number of times to retry sending the webhook, with a delay of 5 seconds between each retry", "webhook_url": "URL where state changes will be sent to using POST request", "webhook_auth_header": "Authorization header to be sent with the request", "webhook_headers": "Additional headers to be sent. Format: key1: value1 on each line" diff --git a/tests/conftest.py b/tests/conftest.py index 1ca4d8c..c35705e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import Generator +from unittest.mock import patch import pytest from homeassistant import loader @@ -50,3 +51,9 @@ def device_reg(hass: HomeAssistant) -> DeviceRegistry: def entity_reg(hass: HomeAssistant) -> EntityRegistry: """Return an empty, loaded, registry.""" return mock_registry(hass) + + +@pytest.fixture(autouse=True) +def patch_retry_delay() -> Generator: + with patch("custom_components.state_webhook.RETRY_DELAY", 0): + yield diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e4decb1..9379c0a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -11,9 +11,16 @@ from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.state_webhook import CONF_ENTITY_DOMAIN, CONF_ENTITY_ID, CONF_FILTER_MODE, CONF_PAYLOAD_OLD_STATE, CONF_WEBHOOK_URL +from custom_components.state_webhook import ( + CONF_ENTITY_DOMAIN, + CONF_ENTITY_ID, + CONF_FILTER_MODE, + CONF_PAYLOAD_OLD_STATE, + CONF_RETRY_LIMIT, + CONF_WEBHOOK_URL, +) from custom_components.state_webhook.config_flow import validate_webhook -from custom_components.state_webhook.const import CONF_PAYLOAD_ATTRIBUTES, DOMAIN, FilterMode +from custom_components.state_webhook.const import CONF_PAYLOAD_ATTRIBUTES, DEFAULT_RETRY_LIMIT, DOMAIN, FilterMode async def test_validate_url() -> None: @@ -85,6 +92,7 @@ async def test_config_flow(hass: HomeAssistant) -> None: assert result["options"] == { CONF_PAYLOAD_ATTRIBUTES: True, CONF_PAYLOAD_OLD_STATE: True, + CONF_RETRY_LIMIT: DEFAULT_RETRY_LIMIT, CONF_NAME: "Test", CONF_WEBHOOK_URL: "http://example.com", CONF_FILTER_MODE: FilterMode.OR, diff --git a/tests/test_init.py b/tests/test_init.py index 0c38d2f..a2c53cb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry from pytest_homeassistant_custom_component.common import MockConfigEntry, mock_registry -from custom_components.state_webhook import CONF_ENTITY_DOMAIN, CONF_WEBHOOK_URL, async_setup_entry, build_payload +from custom_components.state_webhook import CONF_ENTITY_DOMAIN, CONF_ENTITY_ID, CONF_WEBHOOK_URL, async_setup_entry, build_payload from custom_components.state_webhook.const import CONF_PAYLOAD_ATTRIBUTES, CONF_PAYLOAD_OLD_STATE, DOMAIN DEFAULT_WEBHOOK_URL = "https://example.com/webhook" @@ -59,6 +59,30 @@ async def test_state_webhook_triggered_successfully(hass: HomeAssistant) -> None headers={}, ) +async def test_retry_when_webhook_unavailable(hass: HomeAssistant) -> None: + hass.states.async_set("input_boolean.test", "off") + await hass.async_block_till_done() + + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_WEBHOOK_URL: DEFAULT_WEBHOOK_URL, + CONF_ENTITY_ID: ["input_boolean.test"], + }, + ) + + await async_setup_entry(hass, entry) + + with aioresponses() as http_mock: + http_mock.post(DEFAULT_WEBHOOK_URL, status=500, repeat=2) + http_mock.post(DEFAULT_WEBHOOK_URL, status=200) + + hass.states.async_set("input_boolean.test", "on") + await hass.async_block_till_done() + + total_calls = sum(len(calls) for calls in http_mock.requests.values()) + assert total_calls == 3 + @pytest.mark.parametrize( "options,expected_payload",