Skip to content

Instantly share code, notes, and snippets.

@mharizanov
Last active August 30, 2026 13:29
Show Gist options
  • Select an option

  • Save mharizanov/a82488eeca4f213dad7a71368562cf07 to your computer and use it in GitHub Desktop.

Select an option

Save mharizanov/a82488eeca4f213dad7a71368562cf07 to your computer and use it in GitHub Desktop.
Home Assistant storm cell / hail alerts: RainViewer radar decoding + Blitzortung + MeteoAlarm (harizanov.com)

Storm cell / hail alerts for Home Assistant (RainViewer radar + Blitzortung + MeteoAlarm)

Companion code for the blog post: Storm cell and hail alerts in Home Assistant from RainViewer radar https://harizanov.com/?p=8869

Three layers of lead time, plus the same thing for a car tracked via TeslaMate.

Files

File What it is
rainviewer_dbz.py Fetches RainViewer PNG radar tiles around a lat/lon, decodes pixels back to dBZ, clusters >=35 dBZ echoes into cells, tracks them across the last 3 frames and reports if one is on approach course (with ETA). Prints JSON.
rainviewer_ub_palette.json RGB -> palette-index lookup for RainViewer's "Universal Blue" scheme (dBZ = index - 32). The tile server ignores the color parameter and always serves this palette.
storm_alerts.yaml HA package: MeteoAlarm + Blitzortung + the command_line sensor running the script every 5 min, folded into ONE storm-level state machine (see below): escalation pushes, protective actions (pause EV charging, stop irrigation) and all-clear.
storm_replay.py Replays the state machine over Home Assistant history (/api/history/period JSON) so you can see which pushes a threshold change WOULD have sent during your last storm, before you reload anything.
tesla_storm_watch.yaml HA package: MQTT device tracker from TeslaMate, radar scan at the car's position every 10 min while away, lightning-near-the-car alert.

Setup

  1. Copy rainviewer_dbz.py and rainviewer_ub_palette.json to /config/bin/. Needs Pillow – it is already present in the HA core container.
  2. Test: python3 /config/bin/rainviewer_dbz.py <LAT> <LON> should print JSON with dbz_home, dbz_max_10km, dbz_max_25km, approaching, approach_eta_min, ...
  3. Install the Blitzortung integration (HACS) over zone.home; its sensors are named sensor.home_lightning_distance/azimuth/counter here – rename to match yours.
  4. Drop the two YAML files into /config/packages/ (with homeassistant: packages: !include_dir_named packages in configuration.yaml), replace <LAT> <LON>, the mobile_app_* notify targets, the MeteoAlarm province, and the irrigation/wallbox entities (or delete the protective-actions automation).
  5. tesla_storm_watch.yaml assumes TeslaMate publishing to MQTT under teslamate/cars/1/ and sensor.tesla_latitude/longitude from the TeslaMate MQTT integration.

How the alerting works (v2, 2026-08-30)

The first version had six independent automations, each with its own cooldown. The first real storm showed why that is wrong: 12 pushes in one evening (three of them overnight on a 30 dBZ drizzle band), while the single alert that mattered – a 57 dBZ cell 7 km out, ETA 10 minutes – was silenced by a cooldown from an earlier, weaker alert. A cooldown cannot tell "same storm again" from "same storm, now dangerous".

v2 is a state machine:

level means criteria (any) push
0 clear
1 watch official warning / distant cell MeteoAlarm thunder/hail/orange+ · tracked cell ≥45 dBZ, ETA ≤120 min, ≤60 km active (passive 23–07)
2 warning it is coming lightning <25 km (≥3 strikes) · ≥45 dBZ inside 25 km · cell ≥45 dBZ, ETA ≤60 min, ≤40 km time-sensitive (passive 23–07)
3 imminent tracker says minutes away cell ≥50 dBZ, ETA ≤20 min time-sensitive + protective actions
4 severe measured overhead lightning <10 km (≥3 strikes) · ≥55 dBZ inside 25 km critical (bypasses DND) + protective actions
  • sensor.storm_level is a pure template function of the inputs – no memory.
  • input_number.storm_alerted_level remembers the highest level already announced. A push goes out only when the level rises above it. No cooldowns anywhere, so escalation always gets through and repeats never do.
  • Downgrades are silent. 45 minutes with no radar/lightning threat (level ≤1) ends the episode: one "storm passed" push, EV charging resumed if it was paused. A MeteoAlarm warning that is still active does not hold the episode open.
  • Level 3 vs 4 is the difference between extrapolated and measured. The cell tracker works on coarser zoom-6 tiles and occasionally over-calls (it announced a "50 dBZ, ETA 5 min" cell at 01:11 that never showed up in the 25 km ring) – extrapolation alone should not wake the house.
  • The car package only alerts when the car is more than 25 km from home; nearer than that the home alerts already cover it.

Replayed over the storm that motivated it, v2 sends 7 pushes instead of 12 and both the 7 km cell and the measured hail core get through. To tune thresholds, change them in the storm_level template AND in storm_replay.py (they are mirrored by hand), then:

curl -H "Authorization: Bearer $TOKEN" \
  "http://homeassistant:8123/api/history/period/2026-08-29T00:00:00Z?end_time=2026-08-30T08:00:00Z&filter_entity_id=sensor.radar_dbz_home,sensor.home_lightning_distance,sensor.home_lightning_counter,binary_sensor.meteoalarm_sofia" > history.json
python3 storm_replay.py history.json

Erratum (2026-08-30): the tile-centre bug

The first published rainviewer_dbz.py located home on the tile with standard XYZ-tile fraction math. RainViewer's /{size}/{z}/{lat}/{lon}/... endpoint does not return the XYZ tile that contains the coordinate – it returns a tile centred on it, so home is always at pixel (128, 128). The old maths put "home" ~80 km north / 25 km west of the real one at zoom 7 and ~140 km east at zoom 6: the 25 km ring was measuring the wrong province and the cell tracker reported cells on the wrong side of the house. It surfaced when a 47 dBZ cell sat 30 km WNW of home in plain view on the radar card while the sensor read 0 dBZ and "nothing tracked". Fixed here (px, py = 128, 128). If you copied the earlier version, every dBZ value it produced was for a point far from your coordinates – including the storm replay above, which will need re-running on data collected after the fix.

No warranty – it is a hobby project, check the radar yourself when it matters.

#!/usr/bin/env python3
"""RainViewer radar -> dBZ rings + storm-cell tracking around a point.
Layer 1 (single latest frame, z=7): max dBZ at home / 10 km / 25 km.
Layer 2 (last 3 frames = 20 min baseline, z=6, 100 km radius): clusters
>=35 dBZ pixels into cells (8-connected components, >=3 px), matches them
across frames by nearest centroid (<=25 km jump), derives a velocity
vector, and extrapolates the track: a cell is "approaching" when its
future closest approach to home is <=20 km, ETA <=120 min, speed sane
(3-100 km/h) and max dBZ >=40. Tracking is best-effort: any failure
degrades to ring metrics only.
Palette: raw value = row of the official color table, dBZ = raw - 32
(tile server always serves Universal Blue regardless of the color param).
Usage: rainviewer_dbz.py <lat> <lon>
"""
import io, json, math, sys, urllib.request
from PIL import Image
LAT, LON = float(sys.argv[1]), float(sys.argv[2])
PAL = json.load(open("/config/bin/rainviewer_ub_palette.json"))
RGB2RAW = {tuple(v): int(k) for k, v in PAL.items()}
DIRS = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW']
def fetch(url):
return urllib.request.urlopen(url, timeout=20).read()
maps = json.loads(fetch("https://api.rainviewer.com/public/weather-maps.json"))
past = maps["radar"]["past"]
def tile_grid(frame, z):
n = 2 ** z
img = Image.open(io.BytesIO(fetch(
f"{maps['host']}{frame['path']}/256/{z}/{LAT}/{LON}/2/0_0.png"))).convert("RGBA")
latr = math.radians(LAT)
xf = (LON + 180) / 360 * n
yf = (1 - math.log(math.tan(latr) + 1 / math.cos(latr)) / math.pi) / 2 * n
# The /{z}/{lat}/{lon} endpoint returns a tile CENTRED on (LAT, LON), not
# the XYZ tile containing it - home is always at the tile centre. (The
# XYZ-fraction math used before put "home" ~80 km N / 25 km W at z=7 and
# ~140 km E at z=6; fixed 2026-08-30.)
px, py = 128, 128
kmpp = 40075.017 * math.cos(latr) / n / 256
return img.load(), px, py, kmpp
def ring_max(pix, px, py, kmpp, radius_km):
r = int(radius_km / kmpp)
best = None
for y in range(max(0, py - r), min(256, py + r + 1)):
for x in range(max(0, px - r), min(256, px + r + 1)):
if (x - px) ** 2 + (y - py) ** 2 > r * r:
continue
p = pix[x, y]
if p[3] == 0:
continue
raw = RGB2RAW.get(p[:3])
if raw is not None and (best is None or raw - 32 > best):
best = raw - 32
return best
latest = past[-1]
pix7, px7, py7, k7 = tile_grid(latest, 7)
out = {
"dbz_home": ring_max(pix7, px7, py7, k7, 2),
"dbz_max_10km": ring_max(pix7, px7, py7, k7, 10),
"dbz_max_25km": ring_max(pix7, px7, py7, k7, 25),
"frame": latest["path"],
"frame_time": latest["time"], # epoch of the radar frame, for the watchdog
}
def cells(frame):
pix, px, py, kmpp = tile_grid(frame, 6)
pts = {}
r = int(100 / kmpp)
for y in range(max(0, py - r), min(256, py + r + 1)):
for x in range(max(0, px - r), min(256, px + r + 1)):
p = pix[x, y]
if p[3] == 0:
continue
raw = RGB2RAW.get(p[:3])
if raw is not None and raw - 32 >= 35:
pts[(x, y)] = raw - 32
seen, comps = set(), []
for start in pts:
if start in seen:
continue
stack, comp = [start], []
seen.add(start)
while stack:
c = stack.pop()
comp.append(c)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
nb = (c[0] + dx, c[1] + dy)
if nb in pts and nb not in seen:
seen.add(nb)
stack.append(nb)
if len(comp) >= 3:
w = sum(pts[c] for c in comp)
cx = sum(c[0] * pts[c] for c in comp) / w
cy = sum(c[1] * pts[c] for c in comp) / w
comps.append({"e": (cx - px) * kmpp, "n": (py - cy) * kmpp,
"dbz": max(pts[c] for c in comp), "px": len(comp)})
return comps
approach = {"approaching": False, "approach_dbz": None, "approach_eta_min": None,
"approach_dist_km": None, "approach_from": None, "cells_tracked": 0}
try:
frames = past[-3:]
if len(frames) >= 2:
sets = [cells(f) for f in frames]
cur = sets[-1]
approach["cells_tracked"] = len(cur)
best = None
for c in cur:
# match this cell backwards through older frames
track = [(frames[-1]["time"], c)]
ref = c
for i in range(len(sets) - 2, -1, -1):
cand = [o for o in sets[i]
if math.hypot(o["e"] - ref["e"], o["n"] - ref["n"]) <= 25]
if not cand:
break
ref = min(cand, key=lambda o: math.hypot(o["e"] - ref["e"],
o["n"] - ref["n"]))
track.append((frames[i]["time"], ref))
if len(track) < 2:
continue
(t_new, c_new), (t_old, c_old) = track[0], track[-1]
dt = (t_new - t_old) / 3600.0
if dt <= 0:
continue
ve = (c_new["e"] - c_old["e"]) / dt
vn = (c_new["n"] - c_old["n"]) / dt
speed = math.hypot(ve, vn)
if not 3 <= speed <= 100:
continue
pe, pn = c_new["e"], c_new["n"]
t_ca = -(pe * ve + pn * vn) / (speed * speed)
if t_ca <= 0:
continue # heading away
miss = math.hypot(pe + ve * t_ca, pn + vn * t_ca)
if miss <= 20 and t_ca * 60 <= 120 and c_new["dbz"] >= 40:
cand = {"approaching": True, "approach_dbz": c_new["dbz"],
"approach_eta_min": round(t_ca * 60),
"approach_dist_km": round(math.hypot(pe, pn)),
"approach_from": DIRS[int((((math.degrees(math.atan2(pe, pn)) + 360) % 360 + 11.25) % 360 / 22.5)) % 16],
"cells_tracked": len(cur)}
if best is None or cand["approach_dbz"] > best["approach_dbz"]:
best = cand
if best:
approach = best
except Exception:
pass # tracking is best-effort
out.update(approach)
print(json.dumps(out))
{"22": [99, 97, 89], "23": [102, 99, 90], "24": [105, 102, 92], "25": [108, 104, 93], "26": [111, 107, 95], "27": [114, 110, 97], "28": [117, 112, 98], "29": [120, 115, 100], "30": [124, 117, 101], "31": [127, 120, 103], "32": [130, 123, 105], "33": [133, 125, 106], "34": [136, 128, 108], "35": [139, 130, 109], "36": [142, 133, 111], "37": [146, 136, 113], "38": [158, 147, 117], "39": [170, 158, 121], "40": [182, 169, 126], "41": [194, 180, 130], "42": [206, 192, 135], "43": [210, 196, 139], "44": [214, 200, 143], "45": [218, 204, 147], "46": [222, 208, 151], "47": [136, 221, 238], "48": [108, 209, 235], "49": [81, 197, 232], "50": [54, 186, 229], "51": [27, 174, 226], "52": [0, 163, 224], "53": [0, 154, 213], "54": [0, 145, 202], "55": [0, 136, 191], "56": [0, 127, 180], "57": [0, 119, 170], "58": [0, 112, 163], "59": [0, 105, 156], "60": [0, 98, 149], "61": [0, 91, 142], "62": [0, 85, 136], "63": [0, 81, 128], "64": [0, 78, 120], "65": [0, 74, 112], "66": [0, 71, 104], "67": [255, 238, 0], "68": [255, 224, 0], "69": [255, 210, 0], "70": [255, 197, 0], "71": [255, 183, 0], "72": [255, 170, 0], "73": [255, 159, 0], "74": [255, 149, 0], "75": [255, 139, 0], "76": [255, 129, 0], "77": [255, 68, 0], "78": [242, 54, 0], "79": [230, 40, 0], "80": [217, 27, 0], "81": [205, 13, 0], "82": [193, 0, 0], "83": [168, 0, 0], "84": [143, 0, 0], "85": [118, 0, 0], "86": [93, 0, 0], "87": [255, 170, 255], "88": [255, 159, 255], "89": [255, 149, 255], "90": [255, 139, 255], "91": [255, 129, 255], "92": [255, 119, 255], "93": [255, 108, 255], "94": [255, 98, 255], "95": [255, 88, 255], "96": [255, 78, 255], "97": [255, 255, 255], "98": [255, 255, 255], "99": [255, 255, 255], "100": [255, 255, 255], "101": [255, 255, 255], "102": [255, 255, 255], "103": [255, 255, 255], "104": [255, 255, 255], "105": [255, 255, 255], "106": [255, 255, 255], "107": [0, 255, 0], "108": [0, 255, 0], "109": [0, 255, 0], "110": [0, 255, 0], "111": [0, 255, 0], "112": [0, 255, 0], "113": [0, 255, 0], "114": [0, 255, 0], "115": [0, 255, 0], "116": [0, 255, 0], "117": [0, 255, 0], "118": [0, 255, 0], "119": [0, 255, 0], "120": [0, 255, 0], "121": [0, 255, 0], "122": [0, 255, 0], "123": [0, 255, 0], "124": [0, 255, 0], "125": [0, 255, 0], "126": [0, 255, 0], "127": [0, 255, 0], "151": [206, 255, 255], "152": [205, 255, 255], "153": [204, 255, 255], "154": [203, 255, 255], "155": [203, 255, 255], "156": [202, 255, 255], "157": [201, 255, 255], "158": [200, 255, 255], "159": [199, 255, 255], "160": [199, 255, 255], "161": [198, 255, 255], "162": [197, 255, 255], "163": [196, 255, 255], "164": [195, 255, 255], "165": [195, 255, 255], "166": [194, 255, 255], "167": [193, 255, 255], "168": [192, 255, 255], "169": [191, 255, 255], "170": [191, 255, 255], "171": [184, 248, 255], "172": [178, 242, 255], "173": [171, 235, 255], "174": [165, 229, 255], "175": [159, 223, 255], "176": [152, 216, 255], "177": [146, 210, 255], "178": [139, 203, 255], "179": [133, 197, 255], "180": [127, 191, 255], "181": [120, 184, 255], "182": [114, 178, 255], "183": [107, 171, 255], "184": [101, 165, 255], "185": [95, 159, 255], "186": [91, 155, 255], "187": [88, 152, 255], "188": [85, 149, 255], "189": [82, 146, 255], "190": [79, 143, 255], "191": [75, 139, 255], "192": [72, 136, 255], "193": [69, 133, 255], "194": [66, 130, 255], "195": [63, 127, 255], "196": [59, 123, 255], "197": [56, 120, 255], "198": [53, 117, 255], "199": [50, 114, 255], "200": [47, 111, 255], "201": [43, 107, 255], "202": [40, 104, 255], "203": [37, 101, 255], "204": [34, 98, 255], "205": [31, 95, 255], "206": [27, 91, 255], "207": [24, 88, 255], "208": [21, 85, 255], "209": [18, 82, 255], "210": [15, 79, 255], "211": [12, 75, 255], "212": [9, 72, 255], "213": [6, 69, 255], "214": [2, 66, 255], "215": [0, 63, 255], "216": [0, 59, 255], "217": [0, 56, 255], "218": [0, 53, 255], "219": [0, 50, 255], "220": [0, 47, 255], "221": [0, 43, 255], "222": [0, 40, 255], "223": [0, 37, 255], "224": [0, 34, 255], "225": [0, 31, 255], "226": [0, 27, 255], "227": [0, 24, 255], "228": [0, 21, 255], "229": [0, 18, 255], "230": [0, 15, 255], "231": [0, 12, 255], "232": [0, 9, 255], "233": [0, 6, 255], "234": [0, 2, 255], "235": [0, 0, 255], "236": [0, 0, 255], "237": [0, 0, 255], "238": [0, 0, 255], "239": [0, 0, 255], "240": [0, 0, 255], "241": [0, 0, 255], "242": [0, 0, 255], "243": [0, 0, 255], "244": [0, 0, 255], "245": [0, 0, 255], "246": [0, 0, 255], "247": [0, 0, 255], "248": [0, 0, 255], "249": [0, 0, 255], "250": [0, 0, 255], "251": [0, 0, 255], "252": [0, 0, 255], "253": [0, 0, 255], "254": [0, 0, 255], "255": [0, 0, 255]}
# Storm / hail / thunder alerting (added 2026-08-25, rewritten 2026-08-30 as a
# single state machine — see "Strategy" below).
#
# Inputs (unchanged):
# 1. MeteoAlarm (official NIMH warnings, province "Sofia" regex-matched →
# covers BOTH Sofia city BG411 and Sofia-region BG412 next door;
# your home zone). Hours of lead time, coarse.
# 2. Blitzortung (config entry over zone.home, radius 100 km / 120 min
# window): distance / azimuth / counter sensors. The counter decays as
# the 120-min window slides, the distance does NOT (it keeps the last
# strike) — so every lightning rule is gated on counter >= 3.
# 3. RainViewer radar via /config/bin/rainviewer_dbz.py → sensor.radar_dbz_home
# (max dBZ in the 25 km ring + tracked-cell approach attributes).
#
# Strategy (2026-08-30): the previous six independent automations each had a
# fixed cooldown, which both SPAMMED (re-fired every 90 min all night on a
# ~30 dBZ drizzle band: 12 pushes on 2026-08-29) and MASKED (a 57 dBZ cell
# 7 km out, ETA 10 min, at 20:11 was silenced by a 19:31 cooldown).
# Now:
# - sensor.storm_level is a pure function of the inputs:
# 0 clear · 1 watch · 2 warning · 3 imminent · 4 severe
# - input_number.storm_alerted_level remembers the highest level already
# announced in this episode.
# - A push goes out ONLY when storm_level rises above the announced level
# — no cooldowns anywhere, so escalation always gets through.
# - Downgrades are silent. 45 min at level 0 ends the episode: one
# "all clear" push, EV charging resumed if we paused it.
# - Quiet hours 23:00–07:00: watch/warning are delivered "passive"
# (no sound, no wake). Imminent (tracker-extrapolated) is time-sensitive;
# severe (MEASURED: strike <10 km / >=55 dBZ core in the ring) is
# CRITICAL and bypasses DND. The z=6 cell tracker over-called a "50 dBZ,
# ETA 5 min" cell at 01:11 on 2026-08-30 that never showed in the ring —
# extrapolation alone must not wake the house.
# - The episode ends after 45 min with no radar/lightning threat
# (level <= 1) — a still-active MeteoAlarm "watch" does not hold EV
# charging hostage until the warning expires.
# - Protective actions (stop EV charging, kill irrigation) run on the
# escalation to imminent/severe — i.e. also for a hail core with no
# lightning yet, not only for a <10 km strike as before.
# - Approach-tracker sanity gate: cells > 60 km or ETA > 120 min are
# ignored (long-range extrapolation noise, e.g. the 08:21 dud).
# All storm notifications go to BOTH phones via this group (added 2026-08-26).
notify:
- platform: group
name: storm_alerts
services:
- action: mobile_app_YOUR_PHONE
- action: mobile_app_PARTNER_PHONE
binary_sensor:
- platform: meteoalarm
name: meteoalarm_sofia
country: "bulgaria"
province: "Sofia"
language: "en-GB"
input_number:
storm_alerted_level:
name: "Storm: announced level"
icon: mdi:weather-lightning
min: 0
max: 4
step: 1
mode: box
input_datetime:
storm_episode_start:
name: "Storm: episode start"
has_date: true
has_time: true
input_boolean:
storm_ev_paused:
name: "Storm: EV charging paused"
icon: mdi:ev-station
template:
- sensor:
# Compass direction the lightning is coming from (from azimuth degrees).
- name: "Lightning Approach Direction"
unique_id: lightning_approach_direction
icon: mdi:compass-rose
availability: "{{ states('sensor.home_lightning_azimuth') | is_number }}"
state: >-
{% set az = states('sensor.home_lightning_azimuth') | float(0) %}
{% set dirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'] %}
{{ dirs[((az / 22.5) | round(0, 'floor') | int) % 16] }}
# ---- The storm level: pure function of the inputs -------------------
# Thresholds (keep the replay script bin/storm_replay.py in sync):
# severe (4): lightning <10 km & >=3 strikes · ring >=55 dBZ
# imminent (3): approaching cell >=50 dBZ with ETA <=20 min
# warning (2): lightning <25 km & >=3 strikes · ring >=45 dBZ ·
# approaching >=45 dBZ, ETA <=60 min, dist <=40 km
# watch (1): MeteoAlarm thunder/hail/orange+ · approaching
# >=45 dBZ, ETA <=120 min, dist <=60 km
- name: "Storm level"
unique_id: storm_level
icon: mdi:weather-lightning-rainy
state: >-
{% set ld = states('sensor.home_lightning_distance') | float(999) %}
{% set lc = states('sensor.home_lightning_counter') | int(0) %}
{% set ring = states('sensor.radar_dbz_home') | int(0) %}
{% set ap = state_attr('sensor.radar_dbz_home','approaching') == true %}
{% set adbz = state_attr('sensor.radar_dbz_home','approach_dbz') | int(0) %}
{% set aeta = state_attr('sensor.radar_dbz_home','approach_eta_min') | int(999) %}
{% set adist = state_attr('sensor.radar_dbz_home','approach_dist_km') | int(999) %}
{% set ma = is_state('binary_sensor.meteoalarm_sofia','on') %}
{% set a = state_attr('binary_sensor.meteoalarm_sofia','awareness_type') | default('', true) | lower %}
{% set e = state_attr('binary_sensor.meteoalarm_sofia','event') | default('', true) | lower %}
{% set lvl = (state_attr('binary_sensor.meteoalarm_sofia','awareness_level') | default('0', true))[:1] | int(0) %}
{% set meteo = ma and ('thunder' in a or 'thunder' in e or 'hail' in a or 'hail' in e or lvl >= 3) %}
{% if (ld < 10 and lc >= 3) or ring >= 55 %}4
{% elif ap and adbz >= 50 and aeta <= 20 %}3
{% elif (ld < 25 and lc >= 3) or ring >= 45 or (ap and adbz >= 45 and aeta <= 60 and adist <= 40) %}2
{% elif meteo or (ap and adbz >= 45 and aeta <= 120 and adist <= 60) %}1
{% else %}0{% endif %}
attributes:
reason: >-
{% set ld = states('sensor.home_lightning_distance') | float(999) %}
{% set lc = states('sensor.home_lightning_counter') | int(0) %}
{% set ring = states('sensor.radar_dbz_home') | int(0) %}
{% set ap = state_attr('sensor.radar_dbz_home','approaching') == true %}
{% set adbz = state_attr('sensor.radar_dbz_home','approach_dbz') | int(0) %}
{% set aeta = state_attr('sensor.radar_dbz_home','approach_eta_min') | int(999) %}
{% set adist = state_attr('sensor.radar_dbz_home','approach_dist_km') | int(999) %}
{% set afrom = state_attr('sensor.radar_dbz_home','approach_from') %}
{% set parts = namespace(l=[]) %}
{% if lc >= 3 and ld < 25 %}
{% set parts.l = parts.l + ['lightning ' ~ (ld | round(0) | int) ~ ' km ' ~ states('sensor.lightning_approach_direction') ~ ' (' ~ lc ~ ' strikes/2h)'] %}
{% endif %}
{% if ring >= 45 %}
{% set parts.l = parts.l + [ring ~ ' dBZ on radar within 25 km' ~ (' — hail-capable' if ring >= 55 else '')] %}
{% endif %}
{% if ap and adbz >= 45 and aeta <= 120 and adist <= 60 %}
{% set parts.l = parts.l + [adbz ~ ' dBZ cell ' ~ adist ~ ' km out from ' ~ afrom ~ ', ETA ~' ~ aeta ~ ' min' ~ (' — hail-capable' if adbz >= 55 else '')] %}
{% endif %}
{% if is_state('binary_sensor.meteoalarm_sofia','on') %}
{% set parts.l = parts.l + ['MeteoAlarm: ' ~ (state_attr('binary_sensor.meteoalarm_sofia','event') or 'warning')] %}
{% endif %}
{{ parts.l | join('; ') if parts.l else 'clear' }}
level_name: >-
{{ ['clear','watch','warning','imminent','severe'][states('sensor.storm_level') | int(0)] }}
script:
# Stops EV charging (resumed by the all-clear) and kills any running
# irrigation (not resumed — a lost cycle is harmless in a storm).
storm_protective_actions:
alias: "Storm: protective actions"
mode: single
sequence:
- variables:
ev_charging: "{{ states('sensor.wattpilot_carstate') == 'Charging' }}"
watering: >-
{{ expand('switch.irrigation_valve_6','switch.irrigation_valve_7',
'switch.irrigation_valve_5','switch.irrigation_valve_3',
'switch.irrigation_valve_4','switch.irrigation_valve_2',
'switch.irrigation_valve_1')
| selectattr('state','eq','on') | list | count > 0 }}
- if: "{{ ev_charging }}"
then:
- action: button.press
target:
entity_id: button.wattpilot_stop_charging
- action: input_boolean.turn_on
target:
entity_id: input_boolean.storm_ev_paused
- if: "{{ watering }}"
then:
# Kill the cycle automations first (stops their delay chains),
# then close everything. Both automations are re-enabled at once.
- action: automation.turn_off
target:
entity_id:
- automation.irrigation
- automation.bamboo_irrigation
data:
stop_actions: true
- action: automation.turn_on
target:
entity_id:
- automation.irrigation
- automation.bamboo_irrigation
- action: script.turn_off
target:
entity_id:
- script.pump_irrigation
- script.irrigation
- action: switch.turn_off
target:
entity_id:
- switch.irrigation_valve_6
- switch.irrigation_valve_7
- switch.irrigation_valve_5
- switch.irrigation_valve_3
- switch.irrigation_valve_4
- switch.irrigation_valve_2
- switch.irrigation_valve_1
- if: "{{ ev_charging or watering }}"
then:
- action: notify.storm_alerts
data:
title: "🛡️ Storm actions taken"
message: >-
{{ 'EV charging stopped. ' if ev_charging else '' }}
{{- 'Irrigation stopped and valves closed. ' if watering else '' }}
Will resume charging after 45 min all-clear.
automation:
# ---- Escalation: push only when the level rises above what was announced
- id: storm_escalation
alias: "Storm: escalation"
description: >-
sensor.storm_level rose above input_number.storm_alerted_level. One push
per escalation step, no cooldowns. Quiet hours 23–07 = passive for
watch/warning; imminent is always critical and runs protective actions.
mode: queued
max: 5
triggers:
- trigger: state
entity_id: sensor.storm_level
conditions:
- condition: template
value_template: >-
{{ trigger.to_state.state | int(-1) > states('input_number.storm_alerted_level') | int(0) }}
actions:
- variables:
level: "{{ trigger.to_state.state | int }}"
prev: "{{ states('input_number.storm_alerted_level') | int(0) }}"
reason: "{{ state_attr('sensor.storm_level','reason') }}"
quiet: "{{ now().hour >= 23 or now().hour < 7 }}"
- if: "{{ prev <= 1 }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: input_datetime.storm_episode_start
data:
datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
- action: input_number.set_value
target:
entity_id: input_number.storm_alerted_level
data:
value: "{{ level }}"
- choose:
- conditions: "{{ level == 4 }}"
sequence:
- action: notify.storm_alerts
data:
title: "🌩️ Storm overhead"
message: "{{ reason }}. Move cars under cover, close up outside."
data:
push:
sound:
name: default
critical: 1
volume: 0.8
url: /lovelace/OUT
- action: script.storm_protective_actions
- conditions: "{{ level == 3 }}"
sequence:
- action: notify.storm_alerts
data:
title: "⛈️ Storm cell imminent"
message: "{{ reason }}. Consider moving cars under cover."
data:
push:
interruption-level: time-sensitive
url: /lovelace/OUT
- action: script.storm_protective_actions
- conditions: "{{ level == 2 }}"
sequence:
- action: notify.storm_alerts
data:
title: "⛈️ Storm approaching"
message: "{{ reason }}."
data:
push:
interruption-level: "{{ 'passive' if quiet else 'time-sensitive' }}"
url: /lovelace/OUT
default:
- action: notify.storm_alerts
data:
title: "🌦️ Storm watch"
message: "{{ reason }}."
data:
push:
interruption-level: "{{ 'passive' if quiet else 'active' }}"
url: /lovelace/OUT
# ---- All clear: 45 min without a radar/lightning threat ends the episode
- id: storm_all_clear
alias: "Storm: all clear"
description: >-
Level <= 1 (only MeteoAlarm, or nothing) for 45 min after an announced
warning/imminent: resume EV charging if we paused it, drop the
announced level back to the current one, one summary push. A watch
that simply expires is reset silently.
mode: single
triggers:
- trigger: template
value_template: "{{ states('sensor.storm_level') | int(0) <= 1 }}"
for: "00:45:00"
id: threat_gone
- trigger: state
entity_id: sensor.storm_level
to: "0"
for: "00:45:00"
id: fully_clear
conditions:
- condition: template
value_template: >-
{{ states('input_number.storm_alerted_level') | int(0)
> states('sensor.storm_level') | int(0) }}
actions:
- variables:
announced: "{{ states('input_number.storm_alerted_level') | int(0) }}"
current: "{{ states('sensor.storm_level') | int(0) }}"
peak: "{{ ['clear','watch','warning','imminent','severe'][announced] }}"
since: "{{ states('input_datetime.storm_episode_start') }}"
resumed: "{{ is_state('input_boolean.storm_ev_paused','on') }}"
- if: "{{ resumed }}"
then:
- action: button.press
target:
entity_id: button.wattpilot_start_charging
- action: input_boolean.turn_off
target:
entity_id: input_boolean.storm_ev_paused
- action: input_number.set_value
target:
entity_id: input_number.storm_alerted_level
data:
value: "{{ current }}"
- if: "{{ announced >= 2 }}"
then:
- action: notify.storm_alerts
data:
title: "✅ Storm passed"
message: >-
No radar or lightning threat for 45 min. Episode since {{ since[11:16] }},
peak: {{ peak }}. {{ 'EV charging resumed.' if resumed else '' }}
{{- ' MeteoAlarm warning still active.' if current == 1 else '' }}
data:
push:
interruption-level: "{{ 'passive' if (now().hour >= 23 or now().hour < 7) else 'active' }}"
# ---- RainViewer radar dBZ (same data as the OUT radar card) ---------------
# /config/bin/rainviewer_dbz.py decodes the latest frame's Universal Blue
# pixels back to dBZ (raw = palette row, dBZ = raw - 32). >=55 dBZ sustained
# is the classic hail-core signature. Runs in the core container (has Pillow).
command_line:
- sensor:
name: radar_dbz_home
unique_id: radar_dbz_home
command: 'python3 /config/bin/rainviewer_dbz.py <LAT> <LON>'
scan_interval: 300
command_timeout: 60
value_template: "{{ value_json.dbz_max_25km if value_json.dbz_max_25km is not none else 0 }}"
unit_of_measurement: "dBZ"
json_attributes:
- dbz_home
- dbz_max_10km
- dbz_max_25km
- frame
- approaching
- approach_dbz
- approach_eta_min
- approach_dist_km
- approach_from
- cells_tracked
#!/usr/bin/env python3
"""Replay the storm state machine over HA history. Mirrors the Jinja in
packages/storm_alerts.yaml (sensor.storm_level) — keep thresholds in sync.
usage: storm_replay.py history.json"""
import json, sys
from datetime import datetime, timedelta, timezone
TZ = timezone(timedelta(hours=3))
def ts(s): return datetime.fromisoformat(s.replace('Z','+00:00'))
def num(v, d):
try: return float(v)
except: return d
def level(st):
ld = num(st['ld'], 999); lc = int(num(st['lc'], 0)); ring = int(num(st['ring'], 0))
a = st['radar_attrs'] or {}
ap = a.get('approaching') is True
adbz = int(num(a.get('approach_dbz'), 0)); aeta = int(num(a.get('approach_eta_min'), 999)); adist = int(num(a.get('approach_dist_km'), 999))
ma = st['meteo'] == 'on'; m = st['meteo_attrs'] or {}
at = (m.get('awareness_type') or '').lower(); ev = (m.get('event') or '').lower()
lvl = int(num((m.get('awareness_level') or '0')[:1], 0))
meteo = ma and ('thunder' in at or 'thunder' in ev or 'hail' in at or 'hail' in ev or lvl >= 3)
reasons = []
if lc >= 3 and ld < 25: reasons.append(f"lightning {ld:.0f} km ({lc} strikes)")
if ring >= 45: reasons.append(f"{ring} dBZ in ring")
if ap and adbz >= 45 and aeta <= 120 and adist <= 60: reasons.append(f"{adbz} dBZ cell {adist} km from {a.get('approach_from')}, ETA {aeta}")
if ma: reasons.append(f"MeteoAlarm {m.get('event')}")
if (ld < 10 and lc >= 3) or ring >= 55: L = 4
elif ap and adbz >= 50 and aeta <= 20: L = 3
elif (ld < 25 and lc >= 3) or ring >= 45 or (ap and adbz >= 45 and aeta <= 60 and adist <= 40): L = 2
elif meteo or (ap and adbz >= 45 and aeta <= 120 and adist <= 60): L = 1
else: L = 0
return L, '; '.join(reasons) or 'clear'
hist = json.load(open(sys.argv[1]))
events = []
for series in hist:
for x in series: events.append((ts(x['last_updated']), x['entity_id'], x['state'], x.get('attributes', {})))
events.sort(key=lambda e: e[0])
st = {'ld':'unknown','lc':'0','ring':'0','radar_attrs':{},'meteo':'off','meteo_attrs':{}}
alerted = 0; clear_since = None; pushes = []; lvl_prev = None
names = ['clear','watch','warning','imminent','severe']
for t, eid, s, a in events:
if eid == 'sensor.home_lightning_distance': st['ld'] = s
elif eid == 'sensor.home_lightning_counter': st['lc'] = s
elif eid == 'sensor.radar_dbz_home': st['ring'] = s; st['radar_attrs'] = a
elif eid == 'binary_sensor.meteoalarm_sofia': st['meteo'] = s; st['meteo_attrs'] = a
else: continue
L, why = level(st)
lt = t.astimezone(TZ).strftime('%m-%d %H:%M')
if L != lvl_prev:
print(f" {lt} level {lvl_prev}->{L} [{why}]"); lvl_prev = L
if L > alerted:
quiet = t.astimezone(TZ).hour >= 23 or t.astimezone(TZ).hour < 7
tag = " (CRITICAL)" if L == 4 else " (time-sensitive)" if L == 3 else " (passive)" if quiet else ""
if alerted <= 1: episode = lt
pushes.append((lt, f"PUSH {names[L].upper()}" + tag, why)); alerted = L
if L <= 1:
if clear_since is None: clear_since = t
elif alerted > L and t - clear_since >= timedelta(minutes=45):
if alerted >= 2: pushes.append((t.astimezone(TZ).strftime('%m-%d %H:%M'), "PUSH ALL-CLEAR", f"peak {names[alerted]}, since {episode}"))
alerted = L; clear_since = t
else: clear_since = None
print("\n=== Pushes that would have been sent ===")
for p in pushes: print(" ", *p)
print(f"\n{len(pushes)} pushes (vs 12 actually sent 2026-08-29 12:04 → 08-30 08:21)")
# Tesla away-storm monitor (added 2026-08-27).
#
# Watches for hail/thunder risk AT THE CAR, only while it is away from home
# (home is covered by packages/storm_alerts.yaml). Notification-only by
# design: the TeslaMate stack is read-only, there is no command channel.
#
# Pieces:
# 1. device_tracker.tesla — MQTT tracker fed by the retained
# teslamate/cars/1/location JSON ({"latitude":..,"longitude":..});
# state home/not_home mapped from the TeslaMate geofence topic.
# Registry-backed (unique_id) so Blitzortung's entity mode accepts it.
# 2. Blitzortung config entry "Tesla" (entity mode, radius 50 km) —
# re-centers as the car moves; sensor.tesla_lightning_* are car-relative.
# 3. Radar scan: every 10 min while away, shell_command runs
# bin/rainviewer_dbz.py at the car's coordinates (same dBZ rings +
# cell tracking as home); stdout captured via response_variable,
# no file plumbing. Alerts on >=55 dBZ within 25 km of the car OR an
# approaching >=45 dBZ cell. 90 min cooldown via input_datetime
# (last_triggered is useless here — the scan runs every 10 min).
# All alerts go to notify.storm_alerts (both phones).
mqtt:
device_tracker:
- name: "Tesla"
unique_id: tesla_location_tracker
state_topic: "teslamate/cars/1/geofence"
value_template: "{{ 'home' if value == 'Home' else 'not_home' }}"
json_attributes_topic: "teslamate/cars/1/location"
source_type: gps
icon: mdi:car-electric
shell_command:
# Templated command -> executed directly (no shell); stdout comes back in
# the automation's response_variable.
tesla_radar_scan: "python3 /config/bin/rainviewer_dbz.py {{ lat }} {{ lon }}"
input_datetime:
tesla_storm_last_alert:
name: "Tesla storm: last away-alert"
has_date: true
has_time: true
automation:
# ---- Radar/hail at the car (10-min scan while away) ----------------------
- id: tesla_storm_radar_scan
alias: "Tesla storm: radar scan while away"
description: >-
Runs the radar dBZ + cell-tracking script at the car's position every
10 min while it is away. Alerts both phones on hail-grade radar or an
approaching cell.
mode: single
triggers:
- trigger: time_pattern
minutes: "/10"
conditions:
- condition: template
value_template: "{{ not is_state('device_tracker.tesla', 'home') }}"
# Home alerts already cover the car within ~25 km — avoid the duplicate
# push (2026-08-29 19:30 car + 19:31 home). Added 2026-08-30.
- condition: numeric_state
entity_id: sensor.tesla_distance_from_home
above: 25
- condition: template
value_template: >-
{{ states('sensor.tesla_latitude') | is_number
and states('sensor.tesla_longitude') | is_number }}
actions:
- action: shell_command.tesla_radar_scan
data:
lat: "{{ states('sensor.tesla_latitude') }}"
lon: "{{ states('sensor.tesla_longitude') }}"
response_variable: radar
- variables:
r: "{{ (radar.stdout | from_json) if radar.returncode == 0 else none }}"
- condition: template
value_template: >-
{{ r is not none and (
(r.dbz_max_25km is not none and r.dbz_max_25km >= 55)
or (r.approaching and (r.approach_dbz or 0) >= 45) ) }}
- condition: template
value_template: >-
{{ now().timestamp()
- (states('input_datetime.tesla_storm_last_alert') | as_timestamp(default=0))
> 5400 }}
- action: input_datetime.set_datetime
target:
entity_id: input_datetime.tesla_storm_last_alert
data:
datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
- action: notify.storm_alerts
data:
title: "🧊 Storm risk at the Tesla"
message: >-
{%- if r.approaching and (r.approach_dbz or 0) >= 45 -%}
{{ r.approach_dbz }} dBZ cell approaching the car from
{{ r.approach_from }}, ~{{ r.approach_eta_min }} min out.
{%- else -%}
Radar shows {{ r.dbz_max_25km }} dBZ within 25 km of the car.
{%- endif %}
The car is {{ states('sensor.tesla_distance_from_home') }} km from
home — consider moving it under cover.
data:
push:
interruption-level: time-sensitive
url: "https://maps.google.com/?q={{ states('sensor.tesla_latitude') }},{{ states('sensor.tesla_longitude') }}"
# ---- Lightning near the car (away) ---------------------------------------
- id: tesla_storm_lightning_near
alias: "Tesla storm: lightning near the car"
description: >-
Car-relative Blitzortung distance crossed below 20 km while the car is
away, with sustained activity. 2 h cooldown.
mode: single
triggers:
- trigger: numeric_state
entity_id: sensor.tesla_lightning_distance
below: 20
conditions:
- condition: template
value_template: "{{ not is_state('device_tracker.tesla', 'home') }}"
# Home alerts already cover the car within ~25 km — avoid the duplicate
# push (2026-08-29 19:30 car + 19:31 home). Added 2026-08-30.
- condition: numeric_state
entity_id: sensor.tesla_distance_from_home
above: 25
- condition: numeric_state
entity_id: sensor.tesla_lightning_counter
above: 1
- condition: template
value_template: >-
{{ this.attributes.last_triggered is none
or now() - this.attributes.last_triggered > timedelta(hours=2) }}
actions:
- action: notify.storm_alerts
data:
title: "⚡ Lightning near the Tesla"
message: >-
Lightning {{ states('sensor.tesla_lightning_distance') }} km from
the car ({{ states('sensor.tesla_lightning_counter') }} strikes in
the window). It is {{ states('sensor.tesla_distance_from_home') }} km
from home.
data:
push:
interruption-level: time-sensitive
url: "https://maps.google.com/?q={{ states('sensor.tesla_latitude') }},{{ states('sensor.tesla_longitude') }}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment