Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ Alternatively, click on the button below to add the repository:
1. Go to **Settings** -> **Devices & Services** -> **Integrations**.
2. Click **Add Integration** and search for **Feedparser**.
3. Fill in at least `Name` and `Feed URL`.
4. Optional include/exclude fields are entered as comma-separated values.
4. Set the refresh interval with the duration field (minimum 1 minute).
5. Optional include/exclude fields are entered as comma-separated values.

To update parsing behavior later, open the Feedparser integration card and use **Configure** (options flow).
To update refresh or parsing behavior later, open the Feedparser integration card and use **Configure** (options flow). Saved changes reload only that feed entry so the new settings take effect immediately.

### YAML configuration (legacy)

Expand Down Expand Up @@ -79,7 +80,7 @@ The integration supports both UI setup (recommended) and legacy YAML setup. Most
| `feed_url` | Yes | URL string | - | `https://www.nu.nl/rss/Algemeen` | RSS/Atom feed URL to fetch and parse. Supports `http`, `https`, and `file` in dev/testing. |
| `date_format` | No | string (`strftime`) | `%a, %b %d %I:%M %p` | `%a, %d %b %Y %H:%M:%S %Z` | Output format for date fields in feed entries. |
| `local_time` | No | boolean | `false` | `true` | Converts parsed date values from feed timezone to Home Assistant local timezone. |
| `scan_interval` | No | duration object | `1 hour` | `{ hours: 1, minutes: 30 }` | Polling interval for refreshing feed data. Minimum effective value is 1 minute. |
| `scan_interval` | No | duration object | `1 hour` | `{ hours: 1, minutes: 30 }` | Polling interval for refreshing feed data. UI input must be at least 1 minute. |
| `show_topn` | No | integer | `9999` | `10` | Maximum number of entries exposed in sensor attributes. |
| `remove_summary_image` | No | boolean | `false` | `true` | Strips `<img ...>` tags from the `summary` field. |
| `inclusions` | No | list of strings (YAML) / comma-separated string (UI) | all fields | `title, link, published, image` | If set, only listed fields are kept for each entry. |
Expand All @@ -93,7 +94,7 @@ The integration supports both UI setup (recommended) and legacy YAML setup. Most
- **UI vs YAML input format**:
- UI uses comma-separated text for `inclusions` and `exclusions`.
- YAML uses proper lists.
- UI config flow uses separate scan interval fields for hours/minutes; YAML uses `scan_interval` object keys (`hours`, `minutes`).
- UI uses a single duration control for `scan_interval`; YAML uses `scan_interval` object keys such as `hours` and `minutes`.
- **Date parsing**: if a feed date is malformed, parser behavior depends on feed content and fallback parsing.

Due to how `custom_components` are loaded, it is normal to see a `ModuleNotFoundError` error on first boot after adding this, to resolve it, restart Home-Assistant.
Expand Down
199 changes: 120 additions & 79 deletions custom_components/feedparser/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
ConfigFlow,
FlowResult,
OptionsFlow,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_NAME
from homeassistant.helpers import selector
from requests_file import FileAdapter
from yarl import URL

Expand All @@ -37,9 +39,6 @@
ENTRY_VERSION,
)

CONF_SCAN_INTERVAL_HOURS = "scan_interval_hours"
CONF_SCAN_INTERVAL_MINUTES = "scan_interval_minutes"


def _split_csv(value: str) -> list[str]:
"""Split a comma-separated string into a normalized list."""
Expand Down Expand Up @@ -99,12 +98,23 @@ def _scan_interval_to_dict(value: object) -> dict[str, int]:

def _scan_interval_from_input(user_input: Mapping[str, object]) -> dict[str, int]:
"""Build normalized scan interval dict from flow input."""
if CONF_SCAN_INTERVAL in user_input:
return _scan_interval_to_dict(user_input[CONF_SCAN_INTERVAL])
value = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)

if isinstance(value, timedelta):
total_minutes = int(value.total_seconds() // 60)
elif isinstance(value, Mapping):
days = _to_int(value.get("days"), 0)
hours = _to_int(value.get("hours"), 0)
minutes = _to_int(value.get("minutes"), 0)
seconds = _to_int(value.get("seconds"), 0)
total_minutes = (days * 24 * 60) + (hours * 60) + minutes + (seconds // 60)
else:
total_minutes = int(DEFAULT_SCAN_INTERVAL.total_seconds() // 60)

if total_minutes < 1:
msg = "Refresh interval must be at least 1 minute"
raise vol.Invalid(msg)

hours = _to_int(user_input.get(CONF_SCAN_INTERVAL_HOURS), 0)
minutes = _to_int(user_input.get(CONF_SCAN_INTERVAL_MINUTES), 0)
total_minutes = max(1, (hours * 60) + minutes)
return {
"hours": total_minutes // 60,
"minutes": total_minutes % 60,
Expand Down Expand Up @@ -132,16 +142,24 @@ def _schema_with_defaults(
vol.Required(CONF_DATE_FORMAT, default=date_format): str,
vol.Required(CONF_LOCAL_TIME, default=local_time): bool,
vol.Required(
CONF_SCAN_INTERVAL_HOURS,
default=normalized_scan_interval["hours"],
): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Required(
CONF_SCAN_INTERVAL_MINUTES,
default=normalized_scan_interval["minutes"],
): vol.All(vol.Coerce(int), vol.Range(min=0, max=59)),
CONF_SCAN_INTERVAL,
default=normalized_scan_interval,
): selector.DurationSelector(
selector.DurationSelectorConfig(
allow_negative=False,
enable_day=False,
enable_second=False,
),
),
vol.Required(CONF_SHOW_TOPN, default=show_topn): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
step=1,
mode=selector.NumberSelectorMode.BOX,
),
),
vol.Coerce(int),
vol.Range(min=1),
),
vol.Required(
CONF_REMOVE_SUMMARY_IMAGE,
Expand All @@ -167,9 +185,9 @@ class FeedparserConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = ENTRY_VERSION

@staticmethod
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
def async_get_options_flow(_config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return FeedparserOptionsFlow(config_entry)
return FeedparserOptionsFlow()

async def async_step_user(
self,
Expand All @@ -180,55 +198,64 @@ async def async_step_user(

if user_input is not None:
feed_url = str(user_input[CONF_FEED_URL]).strip()
scan_interval: dict[str, int] | None = None

try:
scan_interval = _scan_interval_from_input(user_input)
except vol.Invalid:
errors[CONF_SCAN_INTERVAL] = "scan_interval_too_short"

try:
parsed_url = URL(feed_url)
except ValueError:
errors[CONF_FEED_URL] = "invalid_url"
else:
if parsed_url.scheme not in ("http", "https", "file"):
errors[CONF_FEED_URL] = "invalid_url"

if not errors:
await self.async_set_unique_id(feed_url)
self._abort_if_unique_id_configured(error="already_configured")

try:
await self.hass.async_add_executor_job(
self._validate_feed_url,
feed_url,
)
except requests.RequestException:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(feed_url)
self._abort_if_unique_id_configured(error="already_configured")

try:
await self.hass.async_add_executor_job(
self._validate_feed_url,
feed_url,
)
except requests.RequestException:
errors["base"] = "cannot_connect"
else:
data = {
CONF_NAME: str(user_input[CONF_NAME]).strip(),
CONF_FEED_URL: feed_url,
}
options = {
CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(),
CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]),
CONF_SCAN_INTERVAL: _scan_interval_from_input(user_input),
CONF_SHOW_TOPN: _to_int(
user_input[CONF_SHOW_TOPN],
DEFAULT_TOPN,
),
CONF_REMOVE_SUMMARY_IMAGE: bool(
user_input[CONF_REMOVE_SUMMARY_IMAGE],
),
CONF_INCLUSIONS: _split_csv(
str(user_input[CONF_INCLUSIONS]).strip(),
),
CONF_EXCLUSIONS: _split_csv(
str(user_input[CONF_EXCLUSIONS]).strip(),
),
}
return cast(
"FlowResult",
self.async_create_entry(
title=data[CONF_NAME],
data=data,
options=options,
),
)
assert scan_interval is not None
data = {
CONF_NAME: str(user_input[CONF_NAME]).strip(),
CONF_FEED_URL: feed_url,
}
options = {
CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(),
CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]),
CONF_SCAN_INTERVAL: scan_interval,
CONF_SHOW_TOPN: _to_int(
user_input[CONF_SHOW_TOPN],
DEFAULT_TOPN,
),
CONF_REMOVE_SUMMARY_IMAGE: bool(
user_input[CONF_REMOVE_SUMMARY_IMAGE],
),
CONF_INCLUSIONS: _split_csv(
str(user_input[CONF_INCLUSIONS]).strip(),
),
CONF_EXCLUSIONS: _split_csv(
str(user_input[CONF_EXCLUSIONS]).strip(),
),
}
return cast(
"FlowResult",
self.async_create_entry(
title=data[CONF_NAME],
data=data,
options=options,
),
)

data_schema = _schema_with_defaults(
include_feed_identity=True,
Expand All @@ -252,33 +279,43 @@ def _validate_feed_url(feed_url: str) -> None:
response.raise_for_status()


class FeedparserOptionsFlow(OptionsFlow):
class FeedparserOptionsFlow(OptionsFlowWithReload):
"""Handle options for Feedparser."""

automatic_reload = True

def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry

async def async_step_init(
self,
user_input: Mapping[str, object] | None = None,
) -> FlowResult:
"""Manage Feedparser options."""
errors: dict[str, str] = {}

if user_input is not None:
options = {
CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(),
CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]),
CONF_SCAN_INTERVAL: _scan_interval_from_input(user_input),
CONF_SHOW_TOPN: _to_int(user_input[CONF_SHOW_TOPN], DEFAULT_TOPN),
CONF_REMOVE_SUMMARY_IMAGE: bool(user_input[CONF_REMOVE_SUMMARY_IMAGE]),
CONF_INCLUSIONS: _split_csv(str(user_input[CONF_INCLUSIONS]).strip()),
CONF_EXCLUSIONS: _split_csv(str(user_input[CONF_EXCLUSIONS]).strip()),
}
return cast("FlowResult", self.async_create_entry(title="", data=options))

merged = {**self._config_entry.data, **self._config_entry.options}
try:
scan_interval = _scan_interval_from_input(user_input)
except vol.Invalid:
errors[CONF_SCAN_INTERVAL] = "scan_interval_too_short"
else:
options = {
CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(),
CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]),
CONF_SCAN_INTERVAL: scan_interval,
CONF_SHOW_TOPN: _to_int(user_input[CONF_SHOW_TOPN], DEFAULT_TOPN),
CONF_REMOVE_SUMMARY_IMAGE: bool(
user_input[CONF_REMOVE_SUMMARY_IMAGE],
),
CONF_INCLUSIONS: _split_csv(
str(user_input[CONF_INCLUSIONS]).strip(),
),
CONF_EXCLUSIONS: _split_csv(
str(user_input[CONF_EXCLUSIONS]).strip(),
),
}
return cast(
"FlowResult",
self.async_create_entry(title="", data=options),
)

merged = {**self.config_entry.data, **self.config_entry.options}
data_schema = _schema_with_defaults(
include_feed_identity=False,
date_format=str(merged.get(CONF_DATE_FORMAT, DEFAULT_DATE_FORMAT)),
Expand All @@ -298,5 +335,9 @@ async def async_step_init(
)
return cast(
"FlowResult",
self.async_show_form(step_id="init", data_schema=data_schema),
self.async_show_form(
step_id="init",
data_schema=data_schema,
errors=errors,
),
)
12 changes: 8 additions & 4 deletions custom_components/feedparser/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ def __init__(
self._scan_interval = scan_interval
self._local_time = local_time
self._entries: list[dict[str, object]] = []
self._attr_extra_state_attributes = {"entries": self._entries}
self._attr_attribution = "Data retrieved using RSS feedparser"
_LOGGER.debug("Feed %s: FeedParserSensor initialized - %s", self.name, self)

Expand Down Expand Up @@ -243,8 +242,11 @@ def update(self: FeedParserSensor) -> None:
self.name,
self.native_value,
)
self._entries.clear() # clear the entries to avoid duplicates
self._entries.extend(self._generate_entries(parsed_feed))
# Replace the list instead of mutating it in place. Home Assistant
# keeps references to nested attribute values in previous State objects, so
# in-place mutation would also change the previous state snapshot and hide
# genuine `entries` attribute changes from state triggers.
self._entries = self._generate_entries(parsed_feed)
_LOGGER.debug(
"Feed %s: Sensor state updated - %s entries",
self.name,
Expand Down Expand Up @@ -432,6 +434,8 @@ def local_time(self: FeedParserSensor, value: bool) -> None:
self._local_time = value

@property
def extra_state_attributes(self: FeedParserSensor) -> dict[str, list]:
def extra_state_attributes(
self: FeedParserSensor,
) -> dict[str, list[dict[str, object]]]:
"""Return entity specific state attributes."""
return {"entries": self.feed_entries}
Loading