Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 38 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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:

Expand All @@ -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
Expand Down
24 changes: 23 additions & 1 deletion custom_components/usgs_earthquakes_feed/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@
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

from .const import (
DOMAIN,
PLATFORMS,
)
from .helpers import format_event, parse_event_time

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -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


Expand Down
56 changes: 56 additions & 0 deletions custom_components/usgs_earthquakes_feed/helpers.py
Original file line number Diff line number Diff line change
@@ -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}"
)
2 changes: 1 addition & 1 deletion custom_components/usgs_earthquakes_feed/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"aio-geojson-usgs-earthquakes==0.3",
"aio-geojson-client==0.12"
],
"version": "1.2.0"
"version": "1.2.1"
}
58 changes: 14 additions & 44 deletions custom_components/usgs_earthquakes_feed/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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", [])
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self._events, key=lambda e: parse_event_time(e), reverse=True
self._events, key=parse_event_time, reverse=True

Avoid unnecessarily wrapping parse_event_time in a lambda. More details.

)[: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
Expand All @@ -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),
}


Expand Down
5 changes: 5 additions & 0 deletions custom_components/usgs_earthquakes_feed/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Loading