Wildfire monitoring with NASA FIRMS — live fire map + proximity alerts, zero custom components

Our vacation home is in northern Greece, and every summer the same question comes up: is that smoke on the horizon something I should worry about? I wanted Home Assistant to answer that — a map of active fires around the house, "nearest fire: X km" sensors, and a push notification if a fire gets within a threshold distance.

There's been an open feature request for NASA FIRMS data since 2021, and I initially assumed I'd have to write a converter script or a custom integration. Turns out: you don't need any custom code at all. Here's the trick.

The key discovery

NASA FIRMS (Fire Information for Resource Management System) provides near-real-time satellite fire detections (VIIRS, 375 m resolution). The documented API only offers CSV — which Home Assistant can't consume directly. But FIRMS also runs MapServer WFS endpoints, and those happily serve GeoJSON if you ask for it:

https://firms.modaps.eosdis.nasa.gov/mapserver/wfs/Europe/YOUR_MAP_KEY/?SERVICE=WFS&REQUEST=GetFeature&VERSION=2.0.0&TYPENAME=ms:fires_noaa20_24hrs&STARTINDEX=0&COUNT=1000&SRSNAME=urn:ogc:def:crs:EPSG::4326&BBOX=39.65,21.84,41.45,24.22,urn:ogc:def:crs:EPSG::4326&outputformat=geojson

That returns a plain GeoJSON FeatureCollection of fire hotspots — which is exactly what the core geo_json_events integration eats for breakfast. No custom component, no script, no proxy. Data updates every 15 minutes on NASA's side.

Step 1 — Get a free MAP_KEY

Register at API - Map Key - NASA | LANCE | FIRMS (free, just an email). Limit is 5,000 requests per 10 minutes — you'll use one every 5 minutes.

Step 2 — Build your URL

Adjust three things in the URL above:

  • Region (/wfs/Europe/): FIRMS regions include Europe, USA_contiguous_and_Hawaii, Australia_NewZealand, SouthEast_Asia, South_America, etc.
  • Source (TYPENAME=ms:fires_noaa20_24hrs): per satellite, 24 h or 7 days — e.g. fires_noaa20_24hrs, fires_noaa21_24hrs, fires_snpp_24hrs, fires_modis_24hrs.
  • BBOX (BBOX=latS,lonW,latN,lonE): a bounding box around your location. For a radius r km around LAT/LON: latS = LAT - r/111, latN = LAT + r/111, lonW = LON - r/(111*cos(LAT)), lonE = LON + r/(111*cos(LAT)).
    *The cosine takes the latitude in degrees — most tools (Excel, Python, many calculators) expect radians, so use cos(radians(LAT)) there; if you get a negative value or a longitude offset of several degrees, radians bit you. Worked example, r = 100 km around 40.55/23.03: r/111 = 0.90 → latS 39.65 / latN 41.45; 111 × cos(40.55°) = 84.3100/84.3 = 1.19 → lonW 21.84 / lonE 24.22 — which is exactly the BBOX in the example URL above.

Step 3 — Add the GeoJSON integration

Settings → Devices & Services → Add Integration → GeoJSON → paste the URL, drag the location pin exactly onto your home (zoom in — an offset pin skews the built-in distance states), set the radius (e.g. 100 km).

You now get one geo_location.* entity per fire hotspot, automatically created and removed as detections come and go — and they show up on the standard map card out of the box:

type: mapgeo_location_sources:  - geo_json_eventsentities:  - zone.homedefault_zoom: 8theme_mode: auto

Optional: repeat Step 3 with fires_noaa21_24hrs and fires_snpp_24hrs for two more satellites — more overpasses per day, shorter detection gaps (at the cost of occasional duplicate markers for the same fire).

Step 4 — Sensors: hotspot count + nearest distance

Two template sensors (I created them as Template helpers via the UI — Settings → Devices & Services → Helpers — but YAML works the same). Count:

{{ states.geo_location | selectattr('attributes.source', 'eq', 'geo_json_events') | list | count }}

Nearest distance in km — computed from your zone's coordinates with distance(), so it stays correct even if the integration pin is slightly off:

{% set zlat = state_attr('zone.home','latitude') %}{% set zlon = state_attr('zone.home','longitude') %}{% set ns = namespace(best=none) %}{% for f in states.geo_location | selectattr('attributes.source','eq','geo_json_events') %}  {% set d = distance(f.attributes.latitude, f.attributes.longitude, zlat, zlon) %}  {% if d is not none and (ns.best is none or d < ns.best) %}{% set ns.best = d %}{% endif %}{% endfor %}{{ ns.best | round(1) if ns.best is not none else none }}

(Unit km, device class distance, state class measurement.)

Step 5 — Proximity alert

alias: "Wildfire proximity warning"description: "Push notification when a FIRMS hotspot comes within 15 km"triggers:  - trigger: numeric_state    entity_id: sensor.fire_nearest_distance    below: 15actions:  - action: notify.mobile_app_your_phone    data:      title: "🔥 Wildfire warning"      message: >-        Fire hotspot {{ states('sensor.fire_nearest_distance') }} km from home        (NASA FIRMS satellite detection).      data:        url: /lovelace/fire   # iOS: opens the map view on tapmode: single

The numeric_state trigger only re-fires after the value has gone back above the threshold — no notification spam while a fire stays close.

Step 6 (optional polish) — readable list + proper map icons

"Where is it?" list — the hotspots have no names, but a markdown card can compute distance, compass direction and the nearest town (small lookup table, no external geocoder). Swap the towns for your region:

{% set zlat = state_attr('zone.home','latitude') %}{% set zlon = state_attr('zone.home','longitude') %}{% set towns = [['Thessaloniki',40.6401,22.9444],['Katerini',40.2719,22.5025],['Serres',41.0856,23.5484],['Polygyros',40.3786,23.4442],['Veria',40.5238,22.2036],['Kilkis',40.9937,22.8753],['Edessa',40.8006,22.047]] %}{% set fires = states.geo_location | selectattr('attributes.source','eq','geo_json_events') | list %}{% if fires | count == 0 %}✅ No active fire hotspots within 100 km.{% else %}{% for f in fires %}{% set flat = f.attributes.latitude %}{% set flon = f.attributes.longitude %}{% set km = distance(flat, flon, zlat, zlon) %}{% set ns = namespace(town='?', td=9999) %}{% for t in towns %}{% set d = distance(flat, flon, t[1], t[2]) %}{% if d < ns.td %}{% set ns.td = d %}{% set ns.town = t[0] %}{% endif %}{% endfor %}{% set brg = (atan2((flon - zlon) * cos(flat * pi / 180), flat - zlat) * 180 / pi + 360) % 360 %}{% set dir = ['N','NE','E','SE','S','SW','W','NW'][(((brg + 22.5) / 45) | int) % 8] %}- 🔥 **{{ km | round(1) }} km {{ dir }}** · near **{{ ns.town }}** ({{ ns.td | round(0) | int }} km){% endfor %}{% endif %}

Map markers: the auto-generated entities render as a "GJE" initials bubble. Two lines of customize_glob plus a small SVG fix that (an emoji as friendly_name does NOT work — the marker initials logic mangles multi-byte characters into "?"):

homeassistant:  customize_glob:    "geo_location.geo_json_events_*":      friendly_name: "Fire hotspot"      entity_picture: /local/fire.svg

/config/www/fire.svg (MDI flame, orange on dark):

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">  <circle cx="12" cy="12" r="12" fill="#212121"/>  <path fill="#ff7043" d="M17.66 11.2C17.43 10.9 17.15 10.64 16.89 10.38C16.22 9.78 15.46 9.35 14.82 8.72C13.33 7.26 13 4.85 13.95 3C13 3.23 12.17 3.75 11.46 4.32C8.87 6.4 7.85 10.07 9.07 13.22C9.11 13.32 9.15 13.42 9.15 13.55C9.15 13.77 9 13.97 8.8 14.05C8.57 14.15 8.33 14.09 8.14 13.93C8.08 13.88 8.04 13.83 8 13.76C6.87 12.33 6.69 10.28 7.45 8.64C5.78 10 4.87 12.3 5 14.47C5.06 14.97 5.12 15.47 5.29 15.97C5.43 16.57 5.7 17.17 6 17.7C7.08 19.43 8.95 20.67 10.96 20.92C13.1 21.19 15.39 20.8 17.03 19.32C18.86 17.66 19.5 15 18.56 12.72L18.43 12.46C18.22 12 17.66 11.2 17.66 11.2M14.5 17.5C14.22 17.74 13.76 18 13.4 18.1C12.28 18.5 11.16 17.94 10.5 17.28C11.69 17 12.4 16.12 12.61 15.23C12.78 14.43 12.46 13.77 12.33 13C12.21 12.26 12.23 11.63 12.5 10.94C12.69 11.32 12.89 11.7 13.13 12C13.9 13 15.11 13.44 15.37 14.8C15.41 14.94 15.43 15.08 15.43 15.23C15.46 16.05 15.1 16.95 14.5 17.5Z"/></svg>

Gotcha: customize is only applied when an entity is (re)created — after changing it, reload the core config and reload the GeoJSON integration (or wait for the next hotspot turnover).

Honest limitations

  • This is not a real-time alarm. VIIRS satellites pass ~2× per day each (using 3 satellites shrinks the gap to a few hours), plus up to ~3 h processing latency. It reliably answers "where is it burning and how far from me" — a freshly ignited fire can be invisible for hours. Pair it with your country's official warning channel (in Europe: the meteoalarm integration has a forest-fire warning type).
  • A hotspot is the center of a 375 m satellite pixel — expect a few hundred meters of positional tolerance, and occasional non-wildfire detections (agricultural burns, industrial heat). The confidence property exists in the raw feed but geo_json_events doesn't filter on it.
  • All distances are great-circle (as the crow flies) — which is the metric you actually want for fires.

Dashboard tile:


pop-up:

Credits

Fire data: NASA FIRMS (https://firms.modaps.eosdis.nasa.gov/), free for this kind of use — don't share URLs containing your personal MAP_KEY.

Happy to answer questions — and if there's enough interest, I'm considering wrapping this into a proper HACS integration (config flow, confidence filtering, multi-satellite dedupe).

Disclaimer: This setup and write-up were created with the help of AI (Claude). Everything described is deployed and running live on my own instance (HA 2026.7) — the templates and configs above are copied from that working setup, not generated blindly.

I think there's an error in here.

shouldn't the lonE & lonW be:

lonW = LON - r/(111*cos(LON))`, `lonE = LON + r/(111*cos(LON)

otherwise I get ridiculous numbers for lonW & lonE.

also now that I've added the integration not only do the listed events not show up on my map it also removed all of the zones from the map that used to show up. Not sure if that was just a coincidence or not since I haven't tried to troubleshoot yet.

EDIT:

Maybe the map card changed since I last looked at it? I had to toggle the "toggle grouping" button on the map. And have to every time I leave it and come back. I'm pretty sure that was like that before today.

Thanks for testing it! The formula is actually right — but I think I know what happened. :slightly_smiling_face:

Why cos(LAT) is correct: Longitude degrees get narrower the further you move away from the equator. How much narrower depends on your latitude — that's why the latitude goes into the cosine. The longitude itself plays no role there.

Why you got crazy numbers: Most tools (Excel, Python, most calculators) expect the cosine input in radians, not degrees.

  • cos(40.55°) = 0.76 :white_check_mark:
  • cos(40.55) in radians mode = −0.96 :cross_mark: → negative → the whole box flips and the numbers go crazy

So just use cos(radians(LAT)) — or a calculator set to DEG mode.

Quick check with real numbers (100 km around 40.55 / 23.03):

  • 100 / 111 = 0.90 → latS 39.65, latN 41.45
  • 100 / (111 × 0.76) = 1.19 → lonW 21.84, lonE 24.22

That's exactly the BBOX in my example URL, so you can compare your result against it.

Your missing events are probably the same problem: with a broken BBOX, NASA simply returns an empty list — so Home Assistant never creates any fire entities. Easy test: paste your WFS URL into a browser. You should see "numberMatched": ... in the response. If it says 0 (or shows an error), the BBOX is the problem.

About the zones on the map: that's not related to this setup. The map card got a new "grouping" feature a few releases ago — nearby markers get collapsed into one cluster bubble, and yes, the toggle resets on every reload. Known annoyance, see this thread. Your zones are still there, just hidden inside a cluster.

I've updated the post to mention the radians pitfall.

Thanks for the clarification on the radians thing. I tried it again like that and it worked.

However I had already found a way via the US NOAA website to get the correct Longitude settings.

Using cos(radians(LAT)) gave me numbers that were very close to what I calculated using the NOAA site.

Tho I still wonder what they base the fire events on because it’s showing I have 6 fire events ~11 miles from me. Ironically the place where it says those fires are located are the factory that I work at. I think as an electrician there they would have probably let me know if there were 6 wildfires on the property, one even being inside the building where I work! :grimacing:

So I have no idea what they call a “wildfire event”.

Also thanks for pointing me in the right direction for the grouping thing in the maps. I obviously don’t look at the map very often and never saw that it had changed behavior some time ago. :smile:

Honestly, you’ve just put your finger on the biggest weakness of this whole approach. :slightly_smiling_face:

FIRMS doesn’t detect “wildfires” — it detects heat. The actual product is Active Fire / Thermal Anomalies: the satellite flags anything that’s much hotter in infrared than its surroundings, with no clue why. A furnace, a gas flare, hot exhaust stacks — from orbit they look the same as a small fire. Your factory lighting up is the system working exactly as designed; it just can’t tell “industrial process” from “forest on fire”. (Easy confirmation: open the FIRMS web map, zoom to the factory, pick a long time range — if it’s lit up basically every day, that’s a permanent industrial heat source, not six wildfires. :grinning_face_with_smiling_eyes:)

So this is less a bug you can fix and more a limitation you have to live with. That said, there is a partial workaround — but I want to be upfront about how partial it is:

  • You can add an ignore list of known coordinates (your factory etc.) to the distance sensor, so those spots don’t count toward “nearest fire” and don’t trigger the alert. That kills the false alarms.
  • But — and this is the catch — it only cleans up the sensor and the alarm. The hotspots still show up as markers on the map, because the map pulls everything from the feed directly. Filtering individual points out of the core map card isn’t really something it’s built for.
  • And of course it’s a manual list. You’d have to hunt down the coordinates of every persistent heat source near you and maintain that list by hand. Doable, not exactly elegant.

So the honest summary: this is great for “is there suddenly a new heat source out in the countryside where there shouldn’t be one” — but it will always be a bit noisy around industrial areas, and smoothing that over takes manual effort. Something a proper custom integration could eventually handle better (some of NASA’s archive products do carry a “static source” flag — the near-real-time feed just doesn’t).

Fun coincidence: my own dashboard has been showing a “fire” 19 km north of the house for days now — same coordinates every time, right in Thessaloniki’s industrial belt. Exact same thing. :grinning_face_with_smiling_eyes:

I kind of figured it was something along those lines. but (silly me) I would have thought that the system would be smart enough to know the different between a fire and a hot smokestack. because as you said the smokestack is pretty much always on and fires move.

But that said I guess that means it should be able to detect a house fire if it was large enough. Maybe I can use that to notify me if my own house is on fire? :wink: :laughing: