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/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 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..9cd49e9 100644 --- a/custom_components/usgs_earthquakes_feed/services.yaml +++ b/custom_components/usgs_earthquakes_feed/services.yaml @@ -2,3 +2,8 @@ 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: {}