From 44ca28a656207ae0aa5d858187963185fc4cca0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:57:39 +0000 Subject: [PATCH 1/4] Initial plan From bbcf07be01ec2217f8e1db90afdef3af508440c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:05:17 +0000 Subject: [PATCH 2/4] fix: resolve sensor unknown state and move formatted_events to service action (v1.2.1) - Fix sensor state showing 'unknown': entry.time from aio_geojson is a datetime object; calling .replace('Z', '+00:00') on it raised TypeError (silently caught), leaving _attr_native_value as None. Now handles both datetime objects and strings. - Remove incorrect @callback decorator from async def _async_update_events. - Fix _attr_native_value type annotation (datetime | None, not str | None). - Remove formatted_events from sensor extra_state_attributes. - Add format_events service with SupportsResponse.ONLY, returning formatted events as response variable. - Extract shared parse_event_time() and format_event() helpers to helpers.py to eliminate code duplication and improve exception handling. - Update services.yaml, manifest.json (1.2.1), and CHANGELOG.md. Co-authored-by: Geek-MD <25725990+Geek-MD@users.noreply.github.com> --- CHANGELOG.md | 10 ++++ .../usgs_earthquakes_feed/__init__.py | 24 +++++++- .../usgs_earthquakes_feed/helpers.py | 56 ++++++++++++++++++ .../usgs_earthquakes_feed/manifest.json | 2 +- .../usgs_earthquakes_feed/sensor.py | 58 +++++-------------- .../usgs_earthquakes_feed/services.yaml | 7 +++ 6 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 custom_components/usgs_earthquakes_feed/helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2066d..f4e2ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [1.2.1] - 2026-03-15 + +### Fixed +- **Sensor state "unknown"**: The sensor state was incorrectly showing `unknown` even when events were present. The root cause was that `entry.time` from `aio_geojson_usgs_earthquakes` is a `datetime` object, and the code tried to call `.replace("Z", "+00:00")` on it as if it were a string. `datetime.replace()` does not accept positional string arguments, causing a `TypeError` that was silently caught and left `_attr_native_value` as `None`. +- Removed erroneous `@callback` decorator from `async def _async_update_events` in `sensor.py` (correct pattern for async dispatcher callbacks). + +### Changed +- Removed `formatted_events` attribute from the sensor's `extra_state_attributes`. +- Added new `format_events` action (service) that returns formatted earthquake events in a response variable (`formatted_events`). Call this action with a `response_variable` to get the human-readable text output. + ## [1.2.0] - 2026-03-15 ### Changed diff --git a/custom_components/usgs_earthquakes_feed/__init__.py b/custom_components/usgs_earthquakes_feed/__init__.py index f2982b1..f7b41a4 100644 --- a/custom_components/usgs_earthquakes_feed/__init__.py +++ b/custom_components/usgs_earthquakes_feed/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -12,6 +13,7 @@ DOMAIN, PLATFORMS, ) +from .helpers import format_event, parse_event_time _LOGGER = logging.getLogger(__name__) @@ -52,6 +54,26 @@ async def handle_force_update(call: ServiceCall) -> None: if not hass.services.has_service(DOMAIN, "force_feed_update"): hass.services.async_register(DOMAIN, "force_feed_update", handle_force_update) + # Registrar el servicio format_events con soporte de respuesta + async def handle_format_events(call: ServiceCall) -> dict[str, Any]: + """Servicio: Devuelve los eventos de terremotos formateados como texto.""" + all_events: list[dict[str, Any]] = [] + for entry_data in hass.data.get(DOMAIN, {}).values(): + if isinstance(entry_data, dict): + all_events.extend(entry_data.get("events", [])) + + all_events.sort(key=parse_event_time, reverse=True) + + return {"formatted_events": "\n\n".join(format_event(e) for e in all_events)} + + if not hass.services.has_service(DOMAIN, "format_events"): + hass.services.async_register( + DOMAIN, + "format_events", + handle_format_events, + supports_response=SupportsResponse.ONLY, + ) + return True diff --git a/custom_components/usgs_earthquakes_feed/helpers.py b/custom_components/usgs_earthquakes_feed/helpers.py new file mode 100644 index 0000000..1d138bd --- /dev/null +++ b/custom_components/usgs_earthquakes_feed/helpers.py @@ -0,0 +1,56 @@ +"""Helper utilities for USGS Quakes integration.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from homeassistant.util.dt import as_local + +_LOGGER = logging.getLogger(__name__) + + +def parse_event_time(time_val: Any) -> datetime: + """Parse an event time value that may be a datetime object or an ISO string.""" + if isinstance(time_val, datetime): + return time_val + t_str = str(time_val) if time_val is not None else "" + if t_str.endswith("Z"): + t_str = t_str.replace("Z", "+00:00") + try: + return datetime.fromisoformat(t_str) + except ValueError: + _LOGGER.debug("Could not parse event time: %s", time_val) + return datetime.min + + +def format_event(e: dict[str, Any]) -> str: + """Return a human-readable string for a single earthquake event.""" + t = e.get("time") + try: + if isinstance(t, datetime): + dt = t if t.tzinfo else t.replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(str(t).replace("Z", "+00:00")) + dt_str = as_local(dt).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + _LOGGER.debug("Could not format event time: %s", t) + dt_str = str(t) + + coords = e.get("coordinates") or [None, None] + lat = coords[0] if len(coords) > 0 else None + lon = coords[1] if len(coords) > 1 else None + maps_url = ( + f"https://www.google.com/maps?q={lat},{lon}" + if lat is not None and lon is not None + else "N/A" + ) + + return ( + f"{e.get('title', 'N/A')}\n" + f"Lugar: {e.get('place', 'N/A')}\n" + f"Magnitud: {e.get('magnitude', 'N/A')} Mw\n" + f"Fecha/Hora: {dt_str}\n" + f"Localización: {maps_url}" + ) diff --git a/custom_components/usgs_earthquakes_feed/manifest.json b/custom_components/usgs_earthquakes_feed/manifest.json index 5625077..d79d9b1 100644 --- a/custom_components/usgs_earthquakes_feed/manifest.json +++ b/custom_components/usgs_earthquakes_feed/manifest.json @@ -13,5 +13,5 @@ "aio-geojson-usgs-earthquakes==0.3", "aio-geojson-client==0.12" ], - "version": "1.2.0" + "version": "1.2.1" } diff --git a/custom_components/usgs_earthquakes_feed/sensor.py b/custom_components/usgs_earthquakes_feed/sensor.py index e5c93f7..4c57064 100644 --- a/custom_components/usgs_earthquakes_feed/sensor.py +++ b/custom_components/usgs_earthquakes_feed/sensor.py @@ -3,18 +3,19 @@ from __future__ import annotations from typing import Any -from datetime import datetime +from datetime import datetime, timezone from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local from .const import DOMAIN +from .helpers import parse_event_time import logging @@ -45,7 +46,7 @@ def __init__(self, hass: HomeAssistant, entry_id: str, device_info: DeviceInfo) self._attr_device_info = device_info self._events: list[dict[str, Any]] = [] self._unsub_dispatcher: Any = None - self._attr_native_value: str | None = None + self._attr_native_value: datetime | None = None async def async_added_to_hass(self) -> None: self._unsub_dispatcher = async_dispatcher_connect( @@ -60,7 +61,6 @@ async def async_will_remove_from_hass(self) -> None: self._unsub_dispatcher() self._unsub_dispatcher = None - @callback async def _async_update_events(self) -> None: """Update sensor state from the shared event list.""" new_events = self.hass.data[DOMAIN][self._entry_id].get("events", []) @@ -74,25 +74,23 @@ async def _async_update_events(self) -> None: else: filtered_events = [e for e in new_events if e["id"] not in existing_ids] - def parse_time(e: dict[str, Any]) -> datetime: - t = str(e["time"]) - if t.endswith("Z"): - t = t.replace("Z", "+00:00") - try: - return datetime.fromisoformat(t) - except Exception: - return datetime.min - # Agregar nuevos eventos y reordenar self._events.extend(filtered_events) - self._events = sorted(self._events, key=parse_time, reverse=True)[:MAX_EVENTS] + self._events = sorted( + self._events, key=lambda e: parse_event_time(e), reverse=True + )[:MAX_EVENTS] # Actualizar valor del sensor (fecha del más reciente) if self._events: try: - dt = datetime.fromisoformat(self._events[0]["time"].replace("Z", "+00:00")) + time_val = self._events[0]["time"] + if isinstance(time_val, datetime): + dt = time_val if time_val.tzinfo else time_val.replace(tzinfo=timezone.utc) + else: + dt = datetime.fromisoformat(str(time_val).replace("Z", "+00:00")) self._attr_native_value = as_local(dt) - except Exception: + except (ValueError, AttributeError): + _LOGGER.debug("Could not parse native value from event time: %s", self._events[0].get("time")) self._attr_native_value = None else: self._attr_native_value = None @@ -106,36 +104,8 @@ def parse_time(e: dict[str, Any]) -> datetime: @property def extra_state_attributes(self) -> dict[str, Any]: - formatted_lines = [] - - for e in self._events: - # Fecha y hora local - dt_str = e.get("time", "") - try: - dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00")) - dt_str = as_local(dt).strftime("%Y-%m-%d %H:%M:%S") - except Exception: - pass - - # Coordenadas - coords = e.get("coordinates", [None, None]) - lat = coords[0] - lon = coords[1] - maps_url = f"https://www.google.com/maps?q={lat},{lon}" if lat is not None and lon is not None else "N/A" - - # Formato - text = ( - f"{e.get('title', 'N/A')}\n" - f"Lugar: {e.get('place', 'N/A')}\n" - f"Magnitud: {e.get('magnitude', 'N/A')} Mw\n" - f"Fecha/Hora: {dt_str}\n" - f"Localización: {maps_url}" - ) - formatted_lines.append(text) - return { "events": self._events, - "formatted_events": "\n\n".join(formatted_lines), } diff --git a/custom_components/usgs_earthquakes_feed/services.yaml b/custom_components/usgs_earthquakes_feed/services.yaml index 095688f..9b8b7a5 100644 --- a/custom_components/usgs_earthquakes_feed/services.yaml +++ b/custom_components/usgs_earthquakes_feed/services.yaml @@ -2,3 +2,10 @@ force_feed_update: name: Force Feed Update description: "Manually trigger an immediate update of the USGS Quakes feed." fields: {} + +format_events: + name: Format Events + description: "Returns the stored earthquake events formatted as human-readable text." + fields: {} + response: + optional: false From 4757ae85ea734f966c3cadc3748e2177faa62e71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:11:07 +0000 Subject: [PATCH 3/4] docs: update README.md for v1.2.1 changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove formatted_events sensor attribute reference (moved to service action) - Fix sensor max events: 10 → 50 (matches MAX_EVENTS constant) - Add dedicated section for the new format_events action with usage example - Rename "Manual Feed Refresh" section to "Services / Actions" and document both force_feed_update and format_events services Co-authored-by: Geek-MD <25725990+Geek-MD@users.noreply.github.com> --- README.md | 53 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ee6124f..8031c3e 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,10 @@ - **Maximum Distance** from your location (Radius) - Creates `geo_location` entities for each event. - Includes a special sensor `sensor.usgs_earthquakes_feed_latest` that: - - Stores only **new** earthquake events (based on their unique `id`) - - Exposes a formatted list of recent events: - - Title - - Place - - Magnitude - - Date/time (local) - - Google Maps link to epicenter + - Stores the last **50** new earthquake events (based on their unique `id`) + - State is the timestamp of the most recent event + - Exposes the full list of stored events via the `events` attribute +- Includes a `format_events` action that returns earthquake events as formatted text via a response variable --- @@ -116,22 +113,37 @@ Full list: [USGS GeoJSON Feed Documentation](https://earthquake.usgs.gov/earthqu This sensor exposes: - `state`: Timestamp of the latest event -- `events`: List of the last 10 new earthquakes -- `formatted_events`: Multiline string with summary info +- `events`: List of the last 50 new earthquakes (stored across restarts) -### Example: +--- + +## 📋 Action: `usgs_earthquakes_feed.format_events` + +Returns the stored earthquake events formatted as human-readable text via a response variable. + +### Example automation: + +```yaml +action: usgs_earthquakes_feed.format_events +response_variable: result +# result.formatted_events contains multiline text, e.g.: +``` + +### Example output: ``` M 5.2 - Near Valparaíso, Chile -Place: 8 km NW of Valparaíso -Magnitude: 5.2 Mw -Date/Time: 2025-09-18 04:33:22 -Location: https://www.google.com/maps?q=-33.0458,-71.6197 +Lugar: 8 km NW of Valparaíso +Magnitud: 5.2 Mw +Fecha/Hora: 2025-09-18 04:33:22 +Localización: https://www.google.com/maps?q=-33.0458,-71.6197 ``` --- -## 🚀 Manual Feed Refresh +## 🚀 Services / Actions + +### Force Feed Refresh Call the following service to manually refresh the earthquake feed: @@ -141,6 +153,17 @@ service: usgs_earthquakes_feed.force_feed_update You can trigger this from Developer Tools, automations, or UI buttons. +### Get Formatted Events + +Call this action to retrieve the stored events as formatted text (response variable required): + +```yaml +action: usgs_earthquakes_feed.format_events +response_variable: quake_report +``` + +The variable `quake_report.formatted_events` will contain a multiline string with event details. + --- ## 📓 Notes From e5f67386e29f0d0af906a84bfe1c51cba556228b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:15:25 +0000 Subject: [PATCH 4/4] fix: remove invalid response key from services.yaml to pass hassfest Co-authored-by: Geek-MD <25725990+Geek-MD@users.noreply.github.com> --- custom_components/usgs_earthquakes_feed/services.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/usgs_earthquakes_feed/services.yaml b/custom_components/usgs_earthquakes_feed/services.yaml index 9b8b7a5..9cd49e9 100644 --- a/custom_components/usgs_earthquakes_feed/services.yaml +++ b/custom_components/usgs_earthquakes_feed/services.yaml @@ -7,5 +7,3 @@ format_events: name: Format Events description: "Returns the stored earthquake events formatted as human-readable text." fields: {} - response: - optional: false