diff --git a/README.md b/README.md
index a393c14..ae85109 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -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 `
` 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. |
@@ -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.
diff --git a/custom_components/feedparser/config_flow.py b/custom_components/feedparser/config_flow.py
index 9bcc834..c961551 100644
--- a/custom_components/feedparser/config_flow.py
+++ b/custom_components/feedparser/config_flow.py
@@ -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
@@ -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."""
@@ -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,
@@ -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,
@@ -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,
@@ -180,6 +198,13 @@ 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:
@@ -187,48 +212,50 @@ async def async_step_user(
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,
@@ -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)),
@@ -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,
+ ),
)
diff --git a/custom_components/feedparser/sensor.py b/custom_components/feedparser/sensor.py
index a862a1f..491324a 100644
--- a/custom_components/feedparser/sensor.py
+++ b/custom_components/feedparser/sensor.py
@@ -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)
@@ -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,
@@ -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}
diff --git a/custom_components/feedparser/strings.json b/custom_components/feedparser/strings.json
index 602f69d..2dc9ffd 100644
--- a/custom_components/feedparser/strings.json
+++ b/custom_components/feedparser/strings.json
@@ -3,25 +3,36 @@
"step": {
"user": {
"title": "Feedparser",
- "description": "Create a sensor from an RSS/Atom feed.",
+ "description": "Create a sensor from an RSS/Atom feed. Refresh and parsing options can be changed later.",
"data": {
"name": "Name",
"feed_url": "Feed URL",
"date_format": "Date format",
"local_time": "Convert dates to local time",
- "scan_interval_hours": "Scan interval hours",
- "scan_interval_minutes": "Scan interval minutes",
- "show_topn": "Number of entries",
+ "scan_interval": "Refresh interval",
+ "show_topn": "Maximum entries",
"remove_summary_image": "Remove image tags from summary",
- "inclusions": "Included fields (comma-separated)",
- "exclusions": "Excluded fields (comma-separated)"
+ "inclusions": "Included fields",
+ "exclusions": "Excluded fields"
+ },
+ "data_description": {
+ "name": "Friendly name for the sensor.",
+ "feed_url": "RSS or Atom feed URL. HTTP, HTTPS, and local file URLs are supported.",
+ "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.",
+ "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.",
+ "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.",
+ "remove_summary_image": "Strip `
` tags from the `summary` field. This does not remove a separate `image` field.",
+ "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.",
+ "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions."
}
}
},
"error": {
"already_configured": "This feed is already configured.",
"cannot_connect": "Failed to download the feed.",
- "invalid_url": "URL is invalid."
+ "invalid_url": "URL is invalid.",
+ "scan_interval_too_short": "Refresh interval must be at least 1 minute."
},
"abort": {
"already_configured": "This feed is already configured."
@@ -31,18 +42,29 @@
"step": {
"init": {
"title": "Feedparser options",
- "description": "Update feed parsing behavior.",
+ "description": "Update feed refresh and parsing behavior. Changes are applied immediately by reloading this feed.",
"data": {
"date_format": "Date format",
"local_time": "Convert dates to local time",
- "scan_interval_hours": "Scan interval hours",
- "scan_interval_minutes": "Scan interval minutes",
- "show_topn": "Number of entries",
+ "scan_interval": "Refresh interval",
+ "show_topn": "Maximum entries",
"remove_summary_image": "Remove image tags from summary",
- "inclusions": "Included fields (comma-separated)",
- "exclusions": "Excluded fields (comma-separated)"
+ "inclusions": "Included fields",
+ "exclusions": "Excluded fields"
+ },
+ "data_description": {
+ "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.",
+ "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.",
+ "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.",
+ "remove_summary_image": "Strip `
` tags from the `summary` field. This does not remove a separate `image` field.",
+ "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.",
+ "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions."
}
}
+ },
+ "error": {
+ "scan_interval_too_short": "Refresh interval must be at least 1 minute."
}
}
}
diff --git a/custom_components/feedparser/translations/de.json b/custom_components/feedparser/translations/de.json
new file mode 100644
index 0000000..3f0074e
--- /dev/null
+++ b/custom_components/feedparser/translations/de.json
@@ -0,0 +1,70 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Feedparser",
+ "description": "Erstelle einen Sensor aus einem RSS-/Atom-Feed. Aktualisierungs- und Verarbeitungsoptionen können später geändert werden.",
+ "data": {
+ "name": "Name",
+ "feed_url": "Feed-URL",
+ "date_format": "Datumsformat",
+ "local_time": "Datumsangaben in lokale Zeit umwandeln",
+ "scan_interval": "Aktualisierungsintervall",
+ "show_topn": "Maximale Einträge",
+ "remove_summary_image": "Bild-Tags aus der Zusammenfassung entfernen",
+ "inclusions": "Einbezogene Felder",
+ "exclusions": "Ausgeschlossene Felder"
+ },
+ "data_description": {
+ "name": "Anzeigename für den Sensor.",
+ "feed_url": "URL eines RSS- oder Atom-Feeds. HTTP, HTTPS und lokale Datei-URLs werden unterstützt.",
+ "date_format": "Python-strftime-Format für Feed-Datumswerte, zum Beispiel `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Feed-Zeitstempel vor der Formatierung in die in Home Assistant konfigurierte lokale Zeit umwandeln.",
+ "scan_interval": "Wie oft der Feed aktualisiert wird. Das Mindestintervall beträgt 1 Minute.",
+ "show_topn": "Maximale Anzahl von Einträgen im Attribut `entries`. Große Werte erhöhen die Zustandsgröße.",
+ "remove_summary_image": "Entfernt `
`-Tags aus dem Feld `summary`. Ein separates Feld `image` wird nicht entfernt.",
+ "inclusions": "Optionale, durch Kommas getrennte Positivliste, zum Beispiel `title, link, published`. Leer lassen, um alle Felder außer den ausgeschlossenen beizubehalten.",
+ "exclusions": "Optionale, durch Kommas getrennte Felder, die entfernt werden sollen. Ausschlüsse werden nach den Einschlüssen angewendet."
+ }
+ }
+ },
+ "error": {
+ "already_configured": "Dieser Feed ist bereits konfiguriert.",
+ "cannot_connect": "Der Feed konnte nicht heruntergeladen werden.",
+ "invalid_url": "Die URL ist ungültig.",
+ "scan_interval_too_short": "Das Aktualisierungsintervall muss mindestens 1 Minute betragen."
+ },
+ "abort": {
+ "already_configured": "Dieser Feed ist bereits konfiguriert."
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Feedparser-Optionen",
+ "description": "Passe die Feed-Aktualisierung und -Verarbeitung an. Änderungen werden sofort angewendet, indem dieser Feed neu geladen wird.",
+ "data": {
+ "date_format": "Datumsformat",
+ "local_time": "Datumsangaben in lokale Zeit umwandeln",
+ "scan_interval": "Aktualisierungsintervall",
+ "show_topn": "Maximale Einträge",
+ "remove_summary_image": "Bild-Tags aus der Zusammenfassung entfernen",
+ "inclusions": "Einbezogene Felder",
+ "exclusions": "Ausgeschlossene Felder"
+ },
+ "data_description": {
+ "date_format": "Python-strftime-Format für Feed-Datumswerte, zum Beispiel `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Feed-Zeitstempel vor der Formatierung in die in Home Assistant konfigurierte lokale Zeit umwandeln.",
+ "scan_interval": "Wie oft der Feed aktualisiert wird. Das Mindestintervall beträgt 1 Minute.",
+ "show_topn": "Maximale Anzahl von Einträgen im Attribut `entries`. Große Werte erhöhen die Zustandsgröße.",
+ "remove_summary_image": "Entfernt `
`-Tags aus dem Feld `summary`. Ein separates Feld `image` wird nicht entfernt.",
+ "inclusions": "Optionale, durch Kommas getrennte Positivliste, zum Beispiel `title, link, published`. Leer lassen, um alle Felder außer den ausgeschlossenen beizubehalten.",
+ "exclusions": "Optionale, durch Kommas getrennte Felder, die entfernt werden sollen. Ausschlüsse werden nach den Einschlüssen angewendet."
+ }
+ }
+ },
+ "error": {
+ "scan_interval_too_short": "Das Aktualisierungsintervall muss mindestens 1 Minute betragen."
+ }
+ }
+}
diff --git a/custom_components/feedparser/translations/el.json b/custom_components/feedparser/translations/el.json
new file mode 100644
index 0000000..6cbe972
--- /dev/null
+++ b/custom_components/feedparser/translations/el.json
@@ -0,0 +1,70 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Feedparser",
+ "description": "Δημιουργήστε έναν αισθητήρα από μια ροή RSS/Atom. Οι επιλογές ανανέωσης και επεξεργασίας μπορούν να αλλάξουν αργότερα.",
+ "data": {
+ "name": "Όνομα",
+ "feed_url": "URL ροής",
+ "date_format": "Μορφή ημερομηνίας",
+ "local_time": "Μετατροπή ημερομηνιών σε τοπική ώρα",
+ "scan_interval": "Διάστημα ανανέωσης",
+ "show_topn": "Μέγιστος αριθμός καταχωρίσεων",
+ "remove_summary_image": "Αφαίρεση ετικετών εικόνας από τη σύνοψη",
+ "inclusions": "Συμπεριλαμβανόμενα πεδία",
+ "exclusions": "Εξαιρούμενα πεδία"
+ },
+ "data_description": {
+ "name": "Φιλικό όνομα για τον αισθητήρα.",
+ "feed_url": "URL ροής RSS ή Atom. Υποστηρίζονται HTTP, HTTPS και τοπικά URL αρχείων.",
+ "date_format": "Μορφή Python strftime που χρησιμοποιείται για τις ημερομηνίες της ροής, για παράδειγμα `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Μετατροπή των χρονικών σημάνσεων της ροής στην τοπική ώρα που έχει ρυθμιστεί στο Home Assistant πριν από τη μορφοποίηση.",
+ "scan_interval": "Πόσο συχνά ανανεώνεται η ροή. Το ελάχιστο διάστημα είναι 1 λεπτό.",
+ "show_topn": "Μέγιστος αριθμός καταχωρίσεων που εμφανίζονται στο χαρακτηριστικό `entries`. Μεγάλες τιμές αυξάνουν το μέγεθος της κατάστασης.",
+ "remove_summary_image": "Αφαιρεί τις ετικέτες `
` από το πεδίο `summary`. Δεν αφαιρεί ξεχωριστό πεδίο `image`.",
+ "inclusions": "Προαιρετική λίστα επιτρεπόμενων πεδίων χωρισμένων με κόμμα, για παράδειγμα `title, link, published`. Αφήστε το κενό για να διατηρηθούν όλα τα πεδία εκτός από όσα εξαιρούνται.",
+ "exclusions": "Προαιρετικά πεδία προς αφαίρεση, χωρισμένα με κόμμα. Οι εξαιρέσεις εφαρμόζονται μετά τις συμπεριλήψεις."
+ }
+ }
+ },
+ "error": {
+ "already_configured": "Αυτή η ροή έχει ήδη ρυθμιστεί.",
+ "cannot_connect": "Αποτυχία λήψης της ροής.",
+ "invalid_url": "Το URL δεν είναι έγκυρο.",
+ "scan_interval_too_short": "Το διάστημα ανανέωσης πρέπει να είναι τουλάχιστον 1 λεπτό."
+ },
+ "abort": {
+ "already_configured": "Αυτή η ροή έχει ήδη ρυθμιστεί."
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Επιλογές Feedparser",
+ "description": "Αλλάξτε τη συχνότητα ανανέωσης και τη συμπεριφορά επεξεργασίας της ροής. Οι αλλαγές εφαρμόζονται αμέσως με επαναφόρτωση αυτής της ροής.",
+ "data": {
+ "date_format": "Μορφή ημερομηνίας",
+ "local_time": "Μετατροπή ημερομηνιών σε τοπική ώρα",
+ "scan_interval": "Διάστημα ανανέωσης",
+ "show_topn": "Μέγιστος αριθμός καταχωρίσεων",
+ "remove_summary_image": "Αφαίρεση ετικετών εικόνας από τη σύνοψη",
+ "inclusions": "Συμπεριλαμβανόμενα πεδία",
+ "exclusions": "Εξαιρούμενα πεδία"
+ },
+ "data_description": {
+ "date_format": "Μορφή Python strftime που χρησιμοποιείται για τις ημερομηνίες της ροής, για παράδειγμα `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Μετατροπή των χρονικών σημάνσεων της ροής στην τοπική ώρα που έχει ρυθμιστεί στο Home Assistant πριν από τη μορφοποίηση.",
+ "scan_interval": "Πόσο συχνά ανανεώνεται η ροή. Το ελάχιστο διάστημα είναι 1 λεπτό.",
+ "show_topn": "Μέγιστος αριθμός καταχωρίσεων που εμφανίζονται στο χαρακτηριστικό `entries`. Μεγάλες τιμές αυξάνουν το μέγεθος της κατάστασης.",
+ "remove_summary_image": "Αφαιρεί τις ετικέτες `
` από το πεδίο `summary`. Δεν αφαιρεί ξεχωριστό πεδίο `image`.",
+ "inclusions": "Προαιρετική λίστα επιτρεπόμενων πεδίων χωρισμένων με κόμμα, για παράδειγμα `title, link, published`. Αφήστε το κενό για να διατηρηθούν όλα τα πεδία εκτός από όσα εξαιρούνται.",
+ "exclusions": "Προαιρετικά πεδία προς αφαίρεση, χωρισμένα με κόμμα. Οι εξαιρέσεις εφαρμόζονται μετά τις συμπεριλήψεις."
+ }
+ }
+ },
+ "error": {
+ "scan_interval_too_short": "Το διάστημα ανανέωσης πρέπει να είναι τουλάχιστον 1 λεπτό."
+ }
+ }
+}
diff --git a/custom_components/feedparser/translations/en.json b/custom_components/feedparser/translations/en.json
index 602f69d..2dc9ffd 100644
--- a/custom_components/feedparser/translations/en.json
+++ b/custom_components/feedparser/translations/en.json
@@ -3,25 +3,36 @@
"step": {
"user": {
"title": "Feedparser",
- "description": "Create a sensor from an RSS/Atom feed.",
+ "description": "Create a sensor from an RSS/Atom feed. Refresh and parsing options can be changed later.",
"data": {
"name": "Name",
"feed_url": "Feed URL",
"date_format": "Date format",
"local_time": "Convert dates to local time",
- "scan_interval_hours": "Scan interval hours",
- "scan_interval_minutes": "Scan interval minutes",
- "show_topn": "Number of entries",
+ "scan_interval": "Refresh interval",
+ "show_topn": "Maximum entries",
"remove_summary_image": "Remove image tags from summary",
- "inclusions": "Included fields (comma-separated)",
- "exclusions": "Excluded fields (comma-separated)"
+ "inclusions": "Included fields",
+ "exclusions": "Excluded fields"
+ },
+ "data_description": {
+ "name": "Friendly name for the sensor.",
+ "feed_url": "RSS or Atom feed URL. HTTP, HTTPS, and local file URLs are supported.",
+ "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.",
+ "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.",
+ "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.",
+ "remove_summary_image": "Strip `
` tags from the `summary` field. This does not remove a separate `image` field.",
+ "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.",
+ "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions."
}
}
},
"error": {
"already_configured": "This feed is already configured.",
"cannot_connect": "Failed to download the feed.",
- "invalid_url": "URL is invalid."
+ "invalid_url": "URL is invalid.",
+ "scan_interval_too_short": "Refresh interval must be at least 1 minute."
},
"abort": {
"already_configured": "This feed is already configured."
@@ -31,18 +42,29 @@
"step": {
"init": {
"title": "Feedparser options",
- "description": "Update feed parsing behavior.",
+ "description": "Update feed refresh and parsing behavior. Changes are applied immediately by reloading this feed.",
"data": {
"date_format": "Date format",
"local_time": "Convert dates to local time",
- "scan_interval_hours": "Scan interval hours",
- "scan_interval_minutes": "Scan interval minutes",
- "show_topn": "Number of entries",
+ "scan_interval": "Refresh interval",
+ "show_topn": "Maximum entries",
"remove_summary_image": "Remove image tags from summary",
- "inclusions": "Included fields (comma-separated)",
- "exclusions": "Excluded fields (comma-separated)"
+ "inclusions": "Included fields",
+ "exclusions": "Excluded fields"
+ },
+ "data_description": {
+ "date_format": "Python strftime format used for feed dates, for example `%a, %d %b %Y %H:%M:%S %Z`.",
+ "local_time": "Convert feed timestamps to Home Assistant's configured local time before formatting.",
+ "scan_interval": "How often to refresh the feed. The minimum interval is 1 minute.",
+ "show_topn": "Maximum number of entries exposed in the `entries` attribute. Large values increase state size.",
+ "remove_summary_image": "Strip `
` tags from the `summary` field. This does not remove a separate `image` field.",
+ "inclusions": "Optional comma-separated allowlist, for example `title, link, published`. Leave empty to keep all fields except exclusions.",
+ "exclusions": "Optional comma-separated fields to remove. Exclusions are applied after inclusions."
}
}
+ },
+ "error": {
+ "scan_interval_too_short": "Refresh interval must be at least 1 minute."
}
}
}
diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py
new file mode 100644
index 0000000..ea19025
--- /dev/null
+++ b/tests/test_config_flow.py
@@ -0,0 +1,39 @@
+"""Tests for the Feedparser config flow helpers."""
+
+from datetime import timedelta
+
+import pytest
+import voluptuous as vol
+from homeassistant.config_entries import OptionsFlowWithReload
+
+from custom_components.feedparser.config_flow import (
+ FeedparserOptionsFlow,
+ _scan_interval_from_input,
+)
+from custom_components.feedparser.const import CONF_SCAN_INTERVAL
+
+
+def test_options_flow_reloads_config_entry() -> None:
+ """Test that saving options uses Home Assistant automatic reload support."""
+ assert issubclass(FeedparserOptionsFlow, OptionsFlowWithReload)
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ ({"hours": 0, "minutes": 1}, {"hours": 0, "minutes": 1}),
+ ({"hours": 1, "minutes": 30}, {"hours": 1, "minutes": 30}),
+ (timedelta(hours=2), {"hours": 2, "minutes": 0}),
+ ],
+)
+def test_scan_interval_normalization(value: object, expected: dict[str, int]) -> None:
+ """Test refresh interval normalization."""
+ assert _scan_interval_from_input({CONF_SCAN_INTERVAL: value}) == expected
+
+
+def test_scan_interval_rejects_zero() -> None:
+ """Test that a zero refresh interval is rejected instead of silently changed."""
+ with pytest.raises(vol.Invalid):
+ _scan_interval_from_input(
+ {CONF_SCAN_INTERVAL: {"hours": 0, "minutes": 0}},
+ )
diff --git a/tests/test_sensors.py b/tests/test_sensors.py
index 1010db3..0fdd2f3 100644
--- a/tests/test_sensors.py
+++ b/tests/test_sensors.py
@@ -3,6 +3,7 @@
import re
from contextlib import nullcontext, suppress
from datetime import UTC, datetime
+from pathlib import Path
from typing import TYPE_CHECKING
import feedparser
@@ -156,6 +157,55 @@ def test_update_sensor_entries_time(
assert first_entry_time == first_sensor_entry_time
+def test_update_preserves_previous_attribute_snapshot(tmp_path: Path) -> None:
+ """Test that a new poll does not mutate the previous entries snapshot."""
+ feed_path = tmp_path / "snapshot.xml"
+
+ def write_feed(title: str) -> None:
+ feed_path.write_text(
+ f"""
+
+
+ Snapshot test
+ https://example.com
+ Snapshot test
+ -
+ {title}
+ https://example.com/item
+ Tue, 08 Sep 2026 12:00:00 GMT
+
+
+
+""",
+ encoding="utf-8",
+ )
+
+ feed_sensor = FeedParserSensor(
+ feed=feed_path.absolute().as_uri(),
+ name="snapshot",
+ date_format=DATE_FORMAT,
+ local_time=False,
+ show_topn=1,
+ remove_summary_image=False,
+ inclusions=["title", "link", "published"],
+ exclusions=[],
+ scan_interval=DEFAULT_SCAN_INTERVAL,
+ )
+
+ write_feed("Old title")
+ feed_sensor.update()
+ old_attributes = feed_sensor.extra_state_attributes
+ old_entries = old_attributes["entries"]
+
+ write_feed("New title")
+ feed_sensor.update()
+ new_entries = feed_sensor.extra_state_attributes["entries"]
+
+ assert old_entries is not new_entries
+ assert old_entries[0]["title"] == "Old title"
+ assert new_entries[0]["title"] == "New title"
+
+
def test_check_duplicates(feed_sensor: FeedParserSensor) -> None:
"""Test that the sensor stores only unique entries."""
feed_sensor.update()