Skip to content

Commit

Permalink
Merge pull request #10 from bramstroker/feat/payload-attributes
Browse files Browse the repository at this point in the history
Implement toggle to include attributes in payload
  • Loading branch information
bramstroker authored Dec 22, 2024
2 parents 3b66461 + 737669f commit c9d3773
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 23 deletions.
34 changes: 26 additions & 8 deletions custom_components/state_webhook/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import aiohttp
import homeassistant.helpers.entity_registry as er
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State, callback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.start import async_at_started

Expand All @@ -16,6 +16,8 @@
CONF_ENTITY_ID_GLOB,
CONF_ENTITY_LABELS,
CONF_FILTER_MODE,
CONF_PAYLOAD_ATTRIBUTES,
CONF_PAYLOAD_OLD_STATE,
CONF_WEBHOOK_AUTH_HEADER,
CONF_WEBHOOK_HEADERS,
CONF_WEBHOOK_URL,
Expand Down Expand Up @@ -54,19 +56,17 @@ async def handle_state_change(event: Event[EventStateChangedData]) -> None:
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")

if new_state is None:
return

_LOGGER.debug(
"State change detected for %s: %s -> %s",
entity_id,
old_state.state if old_state else "None",
new_state.state if new_state else "None",
new_state.state,
)

payload = {
"entity_id": entity_id,
"time": new_state.last_updated.isoformat(),
"old_state": old_state.state if old_state else None,
"new_state": new_state.state if new_state else None,
}
payload = build_payload(entry.options, entity_id, old_state, new_state)

async with aiohttp.ClientSession() as session:
await call_webhook(session, webhook_url, headers, payload)
Expand All @@ -88,6 +88,24 @@ async def call_webhook(session: aiohttp.ClientSession, webhook_url: str, headers
_LOGGER.error("Error calling webhook: %s", e)
return False

def build_payload(options: Mapping[str, Any], entity_id: str, old_state: State | None, new_state: State) -> dict[str, Any]:
"""Build payload for webhook request"""
payload = {
"entity_id": entity_id,
"time": new_state.last_updated.isoformat(),
"new_state": new_state.state,
}

include_old_state = bool(options.get(CONF_PAYLOAD_OLD_STATE, True))
if include_old_state and old_state:
payload["old_state"] = old_state.state

include_attributes = bool(options.get(CONF_PAYLOAD_ATTRIBUTES, False))
if include_attributes:
payload["new_state_attributes"] = new_state.attributes

return payload

def prepare_headers(options: Mapping[str, Any]) -> dict[str, str]:
"""Prepare headers for webhook request"""
headers = options.get(CONF_WEBHOOK_HEADERS) or {}
Expand Down
44 changes: 35 additions & 9 deletions custom_components/state_webhook/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from collections.abc import Mapping
from datetime import datetime
from enum import StrEnum
from typing import Any

import aiohttp
Expand All @@ -14,6 +15,7 @@
SchemaFlowMenuStep,
)
from homeassistant.helpers.selector import (
BooleanSelector,
EntitySelector,
EntitySelectorConfig,
LabelSelector,
Expand All @@ -31,6 +33,8 @@
CONF_ENTITY_ID,
CONF_ENTITY_ID_GLOB,
CONF_ENTITY_LABELS,
CONF_PAYLOAD_ATTRIBUTES,
CONF_PAYLOAD_OLD_STATE,
CONF_WEBHOOK_AUTH_HEADER,
CONF_WEBHOOK_HEADERS,
CONF_WEBHOOK_URL,
Expand Down Expand Up @@ -69,8 +73,22 @@
},
)

PAYLOAD_SCHEMA = vol.Schema(
{
vol.Required(CONF_PAYLOAD_OLD_STATE, default=True): BooleanSelector(),
vol.Required(CONF_PAYLOAD_ATTRIBUTES, default=False): BooleanSelector(),
},
)

class Step(StrEnum):
USER = "user"
INIT = "init"
WEBHOOK = "webhook"
FILTER = "filter"
PAYLOAD = "payload"

async def validate_webhook(handler: SchemaCommonFlowHandler, user_input: dict[str, Any]) -> dict[str, Any]:
"""Validate webhook URL and connection."""
try:
url = str(user_input.get(CONF_WEBHOOK_URL))
cv.url(url)
Expand All @@ -96,35 +114,43 @@ async def validate_webhook(handler: SchemaCommonFlowHandler, user_input: dict[st


CONFIG_FLOW = {
"user": SchemaFlowFormStep(
str(Step.USER): SchemaFlowFormStep(
WEBHOOK_SCHEMA,
next_step="filter",
next_step=Step.FILTER,
validate_user_input=validate_webhook,
),
"filter": SchemaFlowFormStep(
str(Step.FILTER): SchemaFlowFormStep(
FILTER_SCHEMA,
next_step=Step.PAYLOAD,
),
str(Step.PAYLOAD): SchemaFlowFormStep(
PAYLOAD_SCHEMA,
),
}

OPTIONS_FLOW = {
"init": SchemaFlowMenuStep(
str(Step.INIT): SchemaFlowMenuStep(
options={
"webhook",
"filter",
Step.WEBHOOK,
Step.FILTER,
Step.PAYLOAD,
},
),
"webhook": SchemaFlowFormStep(
str(Step.WEBHOOK): SchemaFlowFormStep(
WEBHOOK_OPTIONS_SCHEMA,
validate_user_input=validate_webhook,
),
"filter": SchemaFlowFormStep(
str(Step.FILTER): SchemaFlowFormStep(
FILTER_SCHEMA,
),
str(Step.PAYLOAD): SchemaFlowFormStep(
PAYLOAD_SCHEMA,
),
}


class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow for Threshold."""
"""Handle a config or options flow for state webhook."""
VERSION = 1
MINOR_VERSION = 1

Expand Down
2 changes: 2 additions & 0 deletions custom_components/state_webhook/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
CONF_ENTITY_ID_GLOB = "entity_id_glob"
CONF_ENTITY_LABELS = "entity_labels"
CONF_FILTER_MODE = "filter_mode"
CONF_PAYLOAD_ATTRIBUTES = "payload_attributes"
CONF_PAYLOAD_OLD_STATE = "payload_old_state"
CONF_WEBHOOK_URL = "webhook_url"
CONF_WEBHOOK_HEADERS = "webhook_headers"
CONF_WEBHOOK_AUTH_HEADER = "webhook_auth_header"
Expand Down
19 changes: 18 additions & 1 deletion custom_components/state_webhook/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
"entity_id_glob": "Entity ID glob"
},
"title": "Define which entities to track states for"
},
"payload": {
"description": "Configure which data to send in the webhook JSON payload",
"data": {
"payload_old_state": "Include old state",
"payload_attributes": "Include attributes"
},
"title": "JSON payload"
}
}
},
Expand All @@ -33,7 +41,8 @@
"init": {
"menu_options": {
"webhook": "Webhook options",
"filter": "Filter options"
"filter": "Entity filter options",
"payload": "Payload options"
}
},
"webhook": {
Expand All @@ -60,6 +69,14 @@
"entity_id_glob": "Entity ID glob"
},
"title": "Define which entities to track states for"
},
"payload": {
"description": "Configure which data to send in the webhook JSON payload",
"data": {
"payload_old_state": "Include old state",
"payload_attributes": "Include attributes"
},
"title": "JSON payload"
}
}
}
Expand Down
16 changes: 14 additions & 2 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
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_WEBHOOK_URL
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.config_flow import validate_webhook
from custom_components.state_webhook.const import DOMAIN, FilterMode
from custom_components.state_webhook.const import CONF_PAYLOAD_ATTRIBUTES, DOMAIN, FilterMode


async def test_validate_url() -> None:
Expand Down Expand Up @@ -72,8 +72,19 @@ async def test_config_flow(hass: HomeAssistant) -> None:
CONF_ENTITY_ID: ["sensor.test"],
},
)
assert result["type"] is FlowResultType.FORM

result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PAYLOAD_ATTRIBUTES: True,
},
)

assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["options"] == {
CONF_PAYLOAD_ATTRIBUTES: True,
CONF_PAYLOAD_OLD_STATE: True,
CONF_NAME: "Test",
CONF_WEBHOOK_URL: "http://example.com",
CONF_FILTER_MODE: FilterMode.OR,
Expand Down Expand Up @@ -101,6 +112,7 @@ async def test_options_flow(hass: HomeAssistant) -> None:
assert result["step_id"] == "init"
assert result["menu_options"] == {
"webhook",
"payload",
"filter",
}

Expand Down
35 changes: 32 additions & 3 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from collections.abc import Mapping
from typing import Any
from unittest.mock import ANY

import pytest
from aioresponses import aioresponses
from homeassistant.core import HomeAssistant
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
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
from custom_components.state_webhook.const import DOMAIN
from custom_components.state_webhook import CONF_ENTITY_DOMAIN, 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"

Expand Down Expand Up @@ -54,3 +58,28 @@ async def test_state_webhook_triggered_successfully(hass: HomeAssistant) -> None
json={"entity_id": "input_boolean.test", "time": ANY, "new_state": "off", "old_state": "on"},
headers={},
)


@pytest.mark.parametrize(
"options,expected_payload",
[
(
{CONF_PAYLOAD_ATTRIBUTES: True},
{"entity_id": "input_boolean.test", "time": ANY, "old_state": "on", "new_state": "off", "new_state_attributes": {"attr": "value"}},
),
(
{CONF_PAYLOAD_ATTRIBUTES: False},
{"entity_id": "input_boolean.test", "time": ANY, "old_state": "on", "new_state": "off"},
),
(
{CONF_PAYLOAD_OLD_STATE: False},
{"entity_id": "input_boolean.test", "time": ANY, "new_state": "off"},
),
],
)
async def test_build_payload(options: Mapping[str, bool], expected_payload: dict[str, Any]) -> None:
old_state = State("input_boolean.test", STATE_ON, {"attr": "value"})
new_state = State("input_boolean.test", STATE_OFF, {"attr": "value"})

payload = build_payload(options, "input_boolean.test", old_state, new_state)
assert payload == expected_payload

0 comments on commit c9d3773

Please sign in to comment.