From 1b9367473dbd513c177b0f3e8d189b11537fbab9 Mon Sep 17 00:00:00 2001 From: daveshilobod <12087494+daveshilobod@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:14:43 -0700 Subject: [PATCH 1/5] =?UTF-8?q?Add=20K=C4=ABlauea=20Activity=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/kilaueaactivity/README.md | 19 + apps/kilaueaactivity/kilaueaactivity.star | 881 ++++++++++++++++++++++ apps/kilaueaactivity/kilaueaactivity.webp | Bin 0 -> 7542 bytes apps/kilaueaactivity/manifest.yaml | 16 + update_intervals.txt | 1 + 5 files changed, 917 insertions(+) create mode 100644 apps/kilaueaactivity/README.md create mode 100644 apps/kilaueaactivity/kilaueaactivity.star create mode 100644 apps/kilaueaactivity/kilaueaactivity.webp create mode 100644 apps/kilaueaactivity/manifest.yaml diff --git a/apps/kilaueaactivity/README.md b/apps/kilaueaactivity/README.md new file mode 100644 index 000000000..546382b69 --- /dev/null +++ b/apps/kilaueaactivity/README.md @@ -0,0 +1,19 @@ +# Kīlauea Activity + +Kīlauea Activity turns the latest official USGS Hawaiian Volcano Observatory notice into an original animated pixel-art caldera. It intentionally complements Tronbyt's existing webcam-based Kilauea app rather than replacing it. + +The app distinguishes quiet, precursor/building, erupting, warning, and unavailable states. Its status cards show the literal bulletin phase, the USGS alert level and aviation color, and either the update date or a forecast window extracted from the bulletin. A paused eruption can therefore remain labeled **PAUSED** while the caldera uses the warmer building treatment when USGS reports precursor or forecast signals. + +Data comes from the structured USGS HANS endpoint for volcano `332010`: + + + +Responses are cached for five minutes. The app retains the last valid normalized notice as a temporary fallback, marks notices stale after 48 hours, and will not present a notice older than seven days as current. It requires no API key, secrets, user data, or configuration. + +For deterministic visual review, render every primary state in one loop: + +```sh +pixlet render apps/kilaueaactivity fixture=cycle -o kilaueaactivity.webp +``` + +This is an informational display, not an emergency-alerting system. Follow current USGS and civil-defense guidance for safety decisions. diff --git a/apps/kilaueaactivity/kilaueaactivity.star b/apps/kilaueaactivity/kilaueaactivity.star new file mode 100644 index 000000000..9de787820 --- /dev/null +++ b/apps/kilaueaactivity/kilaueaactivity.star @@ -0,0 +1,881 @@ +""" +Applet: Kīlauea Activity +Summary: Live USGS volcano activity +Description: A data-driven pixel-art view of Kīlauea using official USGS updates. +Author: Dave Shilobod +""" + +load("cache.star", "cache") +load("encoding/json.star", "json") +load("http.star", "http") +load("render.star", "render") +load("schema.star", "schema") +load("time.star", "time") + +DEVICE_WIDTH = 64 +DEVICE_HEIGHT = 32 + +HANS_URL = "https://volcanoes.usgs.gov/hans-public/api/volcano/newestForVolcano/332010" +HTTP_CACHE_TTL_SECONDS = 5 * 60 +LAST_GOOD_TTL_SECONDS = 30 * 24 * 60 * 60 +LAST_GOOD_CACHE_KEY = "kilauea-hans-last-good-v1" + +FRAME_DELAY_MS = 100 +SCENE_PHASE_FRAME_COUNT = 18 +SCENE_HOLD_FRAMES = 4 +INFO_FRAME_COUNT = 35 +INFO_SCROLL_DELAY_FRAMES = 4 + +CYCLE_STATES = ["quiet", "building", "erupting", "warning", "unknown"] +CYCLE_SCENE_PHASE_FRAME_COUNT = 6 +CYCLE_SCENE_HOLD_FRAMES = 2 +CYCLE_INFO_FRAME_COUNT = 12 + +COLOR_BLACK = "#02020a" +COLOR_SKY_TOP = "#050518" +COLOR_SKY_MID = "#09092a" +COLOR_SKY_LOW = "#14103a" +COLOR_HAZE = "#251650" +COLOR_RIDGE = "#09091d" +COLOR_ROCK = "#0b0814" +COLOR_ROCK_MID = "#171027" +COLOR_ROCK_LIGHT = "#28183f" +COLOR_TEXT = "#f7f5ff" +COLOR_SUPPORT_TEXT = "#e2dfed" +COLOR_MUTED = "#b8b4cc" +COLOR_STEAM_DARK = "#4a4768" + +ALERT_COLORS = { + "GREEN": "#34c759", + "YELLOW": "#f6d32d", + "ORANGE": "#ff8a1f", + "RED": "#ff3b30", + "UNASSIGNED": COLOR_MUTED, + "UNKNOWN": COLOR_MUTED, +} + +ALERT_SCALE = ["RED", "ORANGE", "YELLOW", "GREEN"] +ALERT_DIM_COLORS = { + "GREEN": "#12351f", + "YELLOW": "#403a10", + "ORANGE": "#43220c", + "RED": "#421015", +} + +MONTHS = [ + ("JANUARY", "JAN"), + ("FEBRUARY", "FEB"), + ("MARCH", "MAR"), + ("APRIL", "APR"), + ("MAY", "MAY"), + ("JUNE", "JUN"), + ("JULY", "JUL"), + ("AUGUST", "AUG"), + ("SEPTEMBER", "SEP"), + ("OCTOBER", "OCT"), + ("NOVEMBER", "NOV"), + ("DECEMBER", "DEC"), +] + +FIXTURE_PAYLOADS = { + "quiet": { + "noticeId": "normal-visual-fixture", + "noticeType": "Daily Update", + "noticeTypeCd": "DU", + "sentUtc": "2026-07-19", + "sent_unixtime": 1784488015, + "noticeSections": [{ + "vnum": "332010", + "vName": "Kilauea", + "alertLevel": "NORMAL", + "colorCode": "GREEN", + "synopsis": "HVO Kilauea GREEN/NORMAL - Kīlauea is not erupting. Monitoring parameters are at background levels.", + }], + }, + "building": { + "noticeId": "DOI-USGS-HVO-2026-07-19T18:28:36+00:00", + "noticeType": "Daily Update", + "noticeTypeCd": "DU", + "sentUtc": "2026-07-19", + "sent_unixtime": 1784488015, + "noticeHighestAlertLevel": "ADVISORY", + "noticeHighestColorCode": "YELLOW", + "noticeSections": [{ + "vnum": "332010", + "vName": "Kilauea", + "alertLevel": "ADVISORY", + "colorCode": "YELLOW", + "synopsis": "HVO Kilauea YELLOW/ADVISORY - The Halemaʻumaʻu eruption is paused. The next fountain episode is likely between July 24 and July 30.", + }], + }, + "erupting": { + "noticeId": "DOI-USGS-HVO-2026-07-15T20:15:42+00:00", + "noticeType": "Volcano Notice for Aviation", + "noticeTypeCd": "VV", + "sentUtc": "2026-07-15", + "sent_unixtime": 1784147784, + "noticeSections": [{ + "vnum": "332010", + "vName": "Kilauea", + "alertLevel": "WATCH", + "colorCode": "ORANGE", + "synopsis": "HVO Kilauea ORANGE/WATCH - Episode 51 of the ongoing Halemaʻumaʻu eruption continues at 10:20 a.m. HST on July 15.", + }], + }, + "warning": { + "noticeId": "DOI-USGS-HVO-2026-04-10T03:31:42+00:00", + "noticeType": "Volcano Notice for Aviation", + "noticeTypeCd": "VV", + "sentUtc": "2026-04-10", + "sent_unixtime": 1775791902, + "noticeSections": [{ + "vnum": "332010", + "vName": "Kilauea", + "alertLevel": "WATCH", + "colorCode": "RED", + "synopsis": "HVO Kilauea RED/WATCH - Episode 44 of the ongoing Halemaʻumaʻu eruption continues as of 5:00 p.m. HST on April 9, but with reduced ground hazards.", + }], + }, +} + +def main(config): + mock_state = config.get("fixture") or config.get("mock_state") + if mock_state and str(mock_state).lower() == "cycle": + return cycle_demo() + if mock_state: + status = mock_status(mock_state) + else: + status = fetch_status() + + preview = config.get("preview") + if preview == "scene": + phase = int(config.get("preview_phase", "3")) % 6 + return render.Root(child = scene_frame(status, phase)) + if preview == "info": + return render.Root( + child = info_animation(status, False), + delay = FRAME_DELAY_MS, + show_full_animation = True, + ) + if preview == "detail": + return render.Root( + child = info_animation(status, True), + delay = FRAME_DELAY_MS, + show_full_animation = True, + ) + + return render.Root( + child = render.Sequence( + children = [ + scene_animation(status), + info_animation(status, False), + info_animation(status, True), + ], + ), + delay = FRAME_DELAY_MS, + max_age = HTTP_CACHE_TTL_SECONDS, + show_full_animation = True, + ) + +def cycle_demo(): + labels = { + "quiet": "QUIET", + "building": "BUILDING", + "erupting": "ERUPTING", + "warning": "WARNING", + "unknown": "NO DATA", + } + children = [] + for state_name in CYCLE_STATES: + status = mock_status(state_name) + status["headline"] = labels[state_name] + children.append( + scene_animation( + status, + phase_frame_count = CYCLE_SCENE_PHASE_FRAME_COUNT, + hold_frames = CYCLE_SCENE_HOLD_FRAMES, + ), + ) + children.append(info_animation(status, False, CYCLE_INFO_FRAME_COUNT)) + + return render.Root( + child = render.Sequence(children = children), + delay = FRAME_DELAY_MS, + max_age = HTTP_CACHE_TTL_SECONDS, + show_full_animation = True, + ) + +def fetch_status(): + now_unix = time.now().unix + response = http.get( + HANS_URL, + headers = {"Accept": "application/json"}, + ttl_seconds = HTTP_CACHE_TTL_SECONDS, + ) + if response.status_code == 200: + status = normalize_notice(response.json(), now_unix) + if status != None and not status.get("expired", True): + status["cached"] = False + cache.set( + LAST_GOOD_CACHE_KEY, + json.encode(status), + ttl_seconds = LAST_GOOD_TTL_SECONDS, + ) + return status + + cached = cache.get(LAST_GOOD_CACHE_KEY) + if cached: + status = json.decode(cached) + refresh_freshness(status, now_unix) + if not status.get("expired", True): + status["cached"] = True + return status + + return unavailable_status() + +def refresh_freshness(status, now_unix): + sent_unix = status.get("sent_unix") + freshness = freshness_state(sent_unix, now_unix) + status["age_seconds"] = age_seconds(sent_unix, now_unix) + status["freshness"] = freshness + status["stale"] = freshness == "stale" + status["expired"] = freshness == "expired" + +def unavailable_status(): + return { + "synopsis": "USGS status is temporarily unavailable.", + "alert_level": "UNKNOWN", + "color_code": "UNKNOWN", + "sent_date": "", + "sent_unix": 0, + "mode": "unknown", + "phase": "unknown", + "headline": "NO DATA", + "freshness": "expired", + "stale": False, + "expired": True, + "cached": False, + } + +def mock_status(mock_state): + state = str(mock_state).lower() + if state == "paused": + state = "building" + payload = FIXTURE_PAYLOADS.get(state) + if payload == None: + return unavailable_status() + + fixture_time = int(payload.get("sent_unixtime", 0)) + status = normalize_notice(payload, fixture_time) + if status == None: + return unavailable_status() + status["cached"] = False + return status + +def scene_frame(status, frame_index): + mode = status.get("mode", "unknown") + phase = frame_index % 6 + accent = alert_color(status.get("color_code", "UNKNOWN")) + + layers = base_scene() + layers.extend(activity_layers(mode, phase)) + layers.extend(scene_alert_ladder(status.get("color_code", "UNKNOWN"))) + + if mode == "warning" and phase % 2 == 0: + layers.extend([ + rect(0, 0, 64, 1, accent), + rect(0, 31, 64, 1, accent), + rect(0, 0, 1, 32, accent), + rect(63, 0, 1, 32, accent), + ]) + + return render.Stack(children = layers) + +def scene_animation( + status, + phase_frame_count = SCENE_PHASE_FRAME_COUNT, + hold_frames = SCENE_HOLD_FRAMES): + frames = [] + for frame_index in range(phase_frame_count): + frame = scene_frame(status, frame_index) + for _ in range(hold_frames): + frames.append(frame) + return render.Animation(children = frames) + +def scene_alert_ladder(color_code): + active = color_code.upper() + layers = [rect(59, 1, 4, 9, COLOR_ROCK_MID)] + for index in range(len(ALERT_SCALE)): + code = ALERT_SCALE[index] + color = ALERT_COLORS[code] if code == active else ALERT_DIM_COLORS[code] + layers.append(rect(60, 2 + index * 2, 2, 1, color)) + return layers + +def base_scene(): + return [ + rect(0, 0, 64, 32, COLOR_SKY_TOP), + rect(0, 7, 64, 5, COLOR_SKY_MID), + rect(0, 12, 64, 4, COLOR_SKY_LOW), + rect(0, 15, 64, 2, COLOR_HAZE), + + # Stars and thin clouds. + rect(7, 4, 1, 1, "#77738f"), + rect(18, 2, 1, 1, "#4d496d"), + rect(46, 5, 1, 1, "#817c91"), + rect(55, 3, 1, 1, "#4d496d"), + rect(3, 9, 8, 1, "#12113a"), + rect(9, 8, 7, 1, "#12113a"), + rect(45, 10, 10, 1, "#191543"), + rect(50, 9, 7, 1, "#191543"), + + # Distant caldera walls. + rect(0, 14, 11, 1, COLOR_RIDGE), + rect(0, 15, 17, 2, COLOR_RIDGE), + rect(47, 14, 17, 1, COLOR_RIDGE), + rect(42, 15, 22, 2, COLOR_RIDGE), + rect(0, 17, 23, 2, COLOR_ROCK), + rect(39, 17, 25, 2, COLOR_ROCK), + + # Crater rim and bowl. + rect(13, 16, 38, 1, COLOR_ROCK_LIGHT), + rect(9, 17, 46, 1, COLOR_ROCK_MID), + rect(7, 18, 50, 1, COLOR_ROCK_LIGHT), + rect(11, 19, 42, 1, COLOR_ROCK_MID), + rect(14, 18, 36, 1, COLOR_BLACK), + rect(12, 19, 40, 2, COLOR_BLACK), + rect(16, 21, 32, 1, "#07050c"), + + # Near slopes and foreground texture. + rect(0, 19, 12, 3, COLOR_ROCK), + rect(52, 19, 12, 3, COLOR_ROCK), + rect(0, 22, 64, 10, COLOR_ROCK), + rect(0, 22, 10, 2, COLOR_ROCK_MID), + rect(54, 22, 10, 2, COLOR_ROCK_MID), + rect(4, 24, 14, 1, COLOR_ROCK_LIGHT), + rect(46, 24, 14, 1, COLOR_ROCK_LIGHT), + rect(10, 26, 14, 1, COLOR_ROCK_MID), + rect(41, 27, 12, 1, COLOR_ROCK_MID), + rect(2, 29, 16, 1, "#211431"), + rect(23, 30, 18, 1, "#130d20"), + rect(48, 30, 13, 1, "#211431"), + rect(6, 31, 9, 1, COLOR_BLACK), + rect(50, 31, 9, 1, COLOR_BLACK), + ] + +def activity_layers(mode, phase): + if mode == "quiet": + return quiet_layers(phase) + if mode == "building": + return building_layers(phase) + if mode == "erupting": + return erupting_layers(phase) + if mode == "warning": + return warning_layers(phase) + return unknown_layers(phase) + +def quiet_layers(phase): + drift = phase // 2 + return [ + rect(31, 19, 2, 1, "#4c1d20"), + rect(31 + drift, 15, 2, 2, COLOR_STEAM_DARK), + rect(32 + drift, 12, 2, 2, "#34314d"), + rect(34 + drift, 10, 2, 1, "#282640"), + ] + +def building_layers(phase): + pulse = ["#7a2418", "#b33b18", "#ed6a1f", "#ff9f24", "#ed6a1f", "#b33b18"][phase] + drift = phase // 2 + return [ + rect(27, 20, 10, 1, "#32121a"), + rect(29, 19, 6, 1, pulse), + rect(31, 18, 2, 1, "#ffd45a" if phase == 3 else pulse), + rect(31 + drift, 14, 3, 3, COLOR_STEAM_DARK), + rect(33 + drift, 11, 3, 3, "#5d5876"), + rect(36 + drift, 9, 3, 2, COLOR_STEAM_DARK), + rect(38 + drift, 7, 2, 2, "#34314d"), + ] + +def erupting_layers(phase): + sway = [-1, 0, 1, 0, -1, 0][phase] + top = [9, 8, 7, 8, 6, 8][phase] + return [ + rect(22, 19, 20, 1, "#5c1714"), + rect(25, 20, 14, 2, "#2d1014"), + rect(28, 18, 8, 2, "#ff5a14"), + rect(30, top, 4, 10, "#e83b10"), + rect(31, top + 2, 2, 9, "#ffad24"), + rect(32 + sway, top - 1, 1, 2, "#ffe66b"), + rect(27 + sway, 11, 2, 2, "#ff6a14"), + rect(36 - sway, 12, 2, 2, "#ff7a18"), + rect(25 - sway, 8, 1, 2, "#ef4514"), + rect(39 + sway, 9, 1, 2, "#ef4514"), + rect(35, 6 + (phase % 2), 1, 2, "#ff9a1f"), + rect(36, 4 + (phase % 3), 3, 3, COLOR_STEAM_DARK), + rect(39, 2 + (phase % 2), 4, 3, "#5d5876"), + rect(43, 1, 5, 3, "#353249"), + ] + +def warning_layers(phase): + sway = [-1, 0, 1, 1, 0, -1][phase] + return [ + rect(0, 14, 64, 3, "#3d0c23"), + rect(9, 17, 46, 1, "#6f141c"), + rect(18, 19, 28, 2, "#641313"), + rect(24, 20, 18, 2, "#371011"), + rect(27, 15, 11, 5, "#ef3510"), + rect(29, 5, 7, 12, "#ff4f10"), + rect(31, 3, 3, 15, "#ffc52d"), + rect(32 + sway, 1, 2, 5, "#fff078"), + rect(24 + sway, 9, 3, 3, "#ff6010"), + rect(21 - sway, 6, 2, 2, "#ec3210"), + rect(39 - sway, 7, 3, 3, "#ff6010"), + rect(43 + sway, 4, 2, 2, "#ec3210"), + rect(36, 1, 5, 4, "#55213d"), + rect(40, 0, 7, 4, "#40203f"), + rect(45, 1, 8, 5, "#2e1c3c"), + ] + +def unknown_layers(phase): + blink = COLOR_MUTED if phase % 2 == 0 else COLOR_STEAM_DARK + return [ + rect(30, 17, 4, 4, blink), + rect(31, 12, 2, 4, blink), + rect(31, 23, 2, 2, blink), + ] + +def info_frame(status, show_detail): + mode = status.get("mode", "unknown") + accent = alert_color(status.get("color_code", "UNKNOWN")) + fallback_label = { + "quiet": "QUIET", + "building": "BUILDING", + "erupting": "ERUPTING", + "warning": "WARNING", + "unknown": "NO DATA", + }.get(mode, "NO DATA") + label = status.get("headline", fallback_label) + if label not in ["QUIET", "PAUSED", "BUILDING", "ERUPTING", "WARNING", "NO DATA"]: + label = fallback_label + + if show_detail: + bottom = detail_line(status) + else: + bottom = alert_line(status) + + return render.Box( + width = DEVICE_WIDTH, + height = DEVICE_HEIGHT, + color = COLOR_BLACK, + child = render.Column( + expanded = True, + main_align = "space_evenly", + cross_align = "center", + children = [ + render.Row( + cross_align = "center", + children = [ + alert_ladder(status.get("color_code", "UNKNOWN")), + render.Box(width = 1, height = 7), + render.Text(content = "USGS", font = "tom-thumb", color = COLOR_SUPPORT_TEXT), + render.Box(width = 2, height = 7), + render.Text(content = "KĪLAUEA", color = COLOR_TEXT), + ], + ), + render.Text(content = label, font = "6x13", color = accent), + render.Marquee( + width = 60, + align = "center", + delay = INFO_SCROLL_DELAY_FRAMES, + child = render.Text( + content = bottom, + font = "5x8", + color = COLOR_SUPPORT_TEXT, + ), + ), + ], + ), + ) + +def info_animation(status, show_detail, frame_count = INFO_FRAME_COUNT): + card = info_frame(status, show_detail) + return render.Animation(children = [card for _ in range(frame_count)]) + +def alert_ladder(color_code): + active = color_code.upper() + children = [] + for index in range(len(ALERT_SCALE)): + code = ALERT_SCALE[index] + color = ALERT_COLORS[code] if code == active else ALERT_DIM_COLORS[code] + children.append(render.Box(width = 2, height = 1, color = color)) + if index < len(ALERT_SCALE) - 1: + children.append(render.Box(width = 2, height = 1)) + return render.Box( + width = 4, + height = 9, + color = COLOR_ROCK_MID, + child = render.Padding( + pad = 1, + child = render.Column(children = children), + ), + ) + +def alert_line(status): + color = status.get("color_code", "UNKNOWN") + alert = status.get("alert_level", "UNKNOWN") + if color == "UNKNOWN" or alert == "UNKNOWN": + return "STATUS UNKNOWN" + line = "%s %s" % (color, alert) + if len(line) <= 15: + return line + return alert[:15] + +def detail_line(status): + mode = status.get("mode", "unknown") + if mode == "building": + forecast = forecast_window(status.get("synopsis", "")) + if forecast: + return "NEXT %s" % forecast + + sent = short_date(status.get("sent_date", "")) + if sent: + if status.get("stale", False): + prefix = "STALE" + elif status.get("cached", False): + prefix = "CACHED" + else: + prefix = "UPDATED" + return "%s %s" % (prefix, sent) + + if mode == "erupting": + return "SUMMIT ERUPTION" + if mode == "warning": + return "FOLLOW USGS" + if mode == "quiet": + return "NO ERUPTION" + return "TRY AGAIN LATER" + +def forecast_window(synopsis): + text = synopsis.upper() + marker = "LIKELY BETWEEN " + start = text.find(marker) + if start == -1: + marker = "FORECAST WINDOW " + start = text.find(marker) + if start == -1: + return "" + + tail = text[start + len(marker):] + end = tail.find(".") + if end != -1: + tail = tail[:end] + + for full, short in MONTHS: + tail = tail.replace(full, short) + + tokens = tail.replace(",", "").split() + if len(tokens) < 2: + return "" + + first_month = tokens[0] + first_day = tokens[1] + if len(tokens) == 2 or "-" in first_day: + return "%s %s" % (first_month, first_day) + + if len(tokens) >= 4 and tokens[2] in ["AND", "TO", "THROUGH", "-"]: + if tokens[3] == first_month and len(tokens) >= 5: + return "%s %s-%s" % (first_month, first_day, tokens[4]) + if len(tokens) >= 5: + return "%s %s-%s %s" % (first_month, first_day, tokens[3], tokens[4]) + + return "" + +def short_date(sent_utc): + parts = sent_utc.split("-") + if len(parts) != 3: + return "" + + month_number = int(parts[1]) + if month_number < 1 or month_number > 12: + return "" + return "%s %d" % (MONTHS[month_number - 1][1], int(parts[2])) + +def alert_color(color_code): + return ALERT_COLORS.get(color_code.upper(), COLOR_MUTED) + +def get_schema(): + return schema.Schema(version = "1", fields = []) + +def rect(x, y, width, height, color): + return render.Padding( + pad = ( + x, + y, + DEVICE_WIDTH - x - width, + DEVICE_HEIGHT - y - height, + ), + child = render.Box(width = width, height = height, color = color), + ) + +# Pure normalization and activity classification for USGS HANS notices. +KILAUEA_VNUM = "332010" + +FRESH_SECONDS = 48 * 60 * 60 +EXPIRED_SECONDS = 7 * 24 * 60 * 60 +FUTURE_TOLERANCE_SECONDS = 6 * 60 * 60 + +_BUILDING_PHRASES = ( + "precursory", + "forecast", + "imminent", + "likely to begin", + "likely between", + "likely within", + "another episode is likely", + "another fountaining episode is likely", + "forecast window", + "inflation", + "re-inflation", + "re-inflating", + "inflating", + "heightened unrest", + "escalating unrest", + "increased unrest", + "unrest is increasing", +) + +_ENDED_PHRASES = ( + " ended", + " stopped", + " ceased", + " paused", + "not erupting", + "not currently erupting", + "no eruption", + "no eruptive activity", +) + +_QUIET_PHRASES = ( + "background level", + "background state", + "no significant activity", + "remains quiet", + "is quiet", +) + +_ACTIVE_PHRASES = ( + "eruption is underway", + "eruption continues", + "currently erupting", + "is erupting", + "began erupting", + "fountaining is underway", + "fountaining activity is currently", + "eruptive activity is occurring", +) + +_ACTIVE_EPISODE_VERBS = ( + " began", + " started", + " continues", + " resumed", +) + +def _contains_any(text, phrases): + for phrase in phrases: + if phrase in text: + return True + return False + +def _status(value): + if type(value) != "string": + return "UNASSIGNED" + value = value.strip().upper() + return value or "UNASSIGNED" + +def _string(value, default = ""): + if type(value) == "string": + return value.strip() + return default + +def _integer(value): + if type(value) == "int": + return value + if type(value) == "float": + converted = int(value) + if value == converted: + return converted + return None + +def matches_vnum(value, target_vnum = KILAUEA_VNUM): + """Return whether a JSON string or integral number identifies Kilauea.""" + if type(value) == "string": + return value.strip() == target_vnum + numeric = _integer(value) + return numeric != None and str(numeric) == target_vnum + +def select_volcano_section(payload, target_vnum = KILAUEA_VNUM): + """Select the requested volcano section without trusting notice-wide maxima.""" + if type(payload) != "dict": + return None + + sections = payload.get("noticeSections") + if type(sections) != "list": + return None + + for section in sections: + if type(section) == "dict" and matches_vnum(section.get("vnum"), target_vnum): + return section + return None + +def clean_synopsis(value): + """Normalize whitespace and remove HANS's leading status prefix.""" + if type(value) != "string": + return "" + + text = value.replace("\r", " ").replace("\n", " ").strip() + delimiter = text.find(" - ") + if delimiter >= 0 and delimiter < 96: + prefix = text[:delimiter].lower() + if "kilauea" in prefix or "kīlauea" in prefix: + return text[delimiter + 3:].strip() + return text + +def _activity_signals(alert_level, color_code, synopsis): + alert = _status(alert_level) + color = _status(color_code) + text = synopsis.lower() if type(synopsis) == "string" else "" + + warning = ( + alert == "WARNING" or + color == "RED" or + " red/warning - " in (" " + text) or + " red/watch - " in (" " + text) + ) + building = _contains_any(text, _BUILDING_PHRASES) + ended = _contains_any(text, _ENDED_PHRASES) + quiet = ended or _contains_any(text, _QUIET_PHRASES) + active = _contains_any(text, _ACTIVE_PHRASES) + if "episode" in text and _contains_any(text, _ACTIVE_EPISODE_VERBS): + active = True + + return { + "alert": alert, + "color": color, + "warning": warning, + "building": building, + "ended": ended, + "paused": " paused" in text, + "quiet": quiet, + "active": active, + "precursory": "precursory" in text, + } + +def classify_activity(alert_level, color_code, synopsis): + """Map official status plus concise HANS language to a visual mode.""" + signals = _activity_signals(alert_level, color_code, synopsis) + + if signals["warning"]: + return "warning" + if signals["active"] and not signals["ended"] and not signals["precursory"]: + return "erupting" + if signals["building"]: + return "building" + if signals["quiet"]: + return "quiet" + if signals["alert"] == "WATCH" or signals["color"] == "ORANGE": + return "building" + if signals["alert"] == "NORMAL" or signals["color"] == "GREEN": + return "quiet" + return "unknown" + +def classify_phase(alert_level, color_code, synopsis): + """Return a literal headline phase separately from the visual mode.""" + signals = _activity_signals(alert_level, color_code, synopsis) + + if signals["warning"]: + return "warning" + if signals["paused"]: + return "paused" + if signals["active"] and not signals["precursory"]: + return "erupting" + if signals["building"]: + return "precursor" + if signals["quiet"] or signals["alert"] == "NORMAL" or signals["color"] == "GREEN": + return "quiet" + if signals["alert"] == "WATCH" or signals["color"] == "ORANGE": + return "precursor" + return "unknown" + +def phase_headline(phase): + return { + "warning": "WARNING", + "erupting": "ERUPTING", + "precursor": "BUILDING", + "paused": "PAUSED", + "quiet": "QUIET", + "unknown": "UNKNOWN", + }.get(phase, "UNKNOWN") + +def age_seconds(sent_unix, now_unix): + """Return clamped age, or None for invalid/excessively future timestamps.""" + sent = _integer(sent_unix) + now = _integer(now_unix) + if sent == None or now == None or sent <= 0 or now <= 0: + return None + if sent > now + FUTURE_TOLERANCE_SECONDS: + return None + if sent > now: + return 0 + return now - sent + +def freshness_state(sent_unix, now_unix): + age = age_seconds(sent_unix, now_unix) + if age == None or age >= EXPIRED_SECONDS: + return "expired" + if age > FRESH_SECONDS: + return "stale" + return "fresh" + +def normalize_notice(payload, now_unix, target_vnum = KILAUEA_VNUM): + """Validate a HANS payload and return a compact deterministic model.""" + section = select_volcano_section(payload, target_vnum) + if section == None: + return None + + sent_unix = _integer(payload.get("sent_unixtime")) + now = _integer(now_unix) + if sent_unix == None or now == None or sent_unix <= 0 or now <= 0: + return None + if sent_unix > now + FUTURE_TOLERANCE_SECONDS: + return None + + raw_synopsis = section.get("synopsis") + synopsis = clean_synopsis(raw_synopsis) + if synopsis == "": + return None + + alert = _status(section.get("alertLevel")) + color = _status(section.get("colorCode")) + mode = classify_activity(alert, color, raw_synopsis) + phase = classify_phase(alert, color, raw_synopsis) + freshness = freshness_state(sent_unix, now) + + return { + "vnum": target_vnum, + "volcano_name": _string(section.get("vName"), "Kilauea") or "Kilauea", + "notice_id": _string(payload.get("noticeId")), + "notice_type": _string(payload.get("noticeType")), + "notice_type_code": _string(payload.get("noticeTypeCd")), + "sent_unix": sent_unix, + "sent_date": _string(payload.get("sentUtc")), + "alert_level": alert, + "color_code": color, + "synopsis": synopsis, + "mode": mode, + "phase": phase, + "headline": phase_headline(phase), + "age_seconds": age_seconds(sent_unix, now), + "freshness": freshness, + "stale": freshness == "stale", + "expired": freshness == "expired", + } diff --git a/apps/kilaueaactivity/kilaueaactivity.webp b/apps/kilaueaactivity/kilaueaactivity.webp new file mode 100644 index 0000000000000000000000000000000000000000..b08c3f60e6f1155be3aacb10f9b1d08283d7a4c1 GIT binary patch literal 7542 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 nB9)p&yJ5{FPMt;Cd0KBtT Date: Sun, 19 Jul 2026 22:15:24 +0000 Subject: [PATCH 2/5] chore: auto-update manifest metadata [skip ci] --- apps/kilaueaactivity/manifest.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/kilaueaactivity/manifest.yaml b/apps/kilaueaactivity/manifest.yaml index d19214a77..8586e272a 100644 --- a/apps/kilaueaactivity/manifest.yaml +++ b/apps/kilaueaactivity/manifest.yaml @@ -14,3 +14,5 @@ tags: - alert - hawaii - volcano +published: '2026-07-19T22:14:43Z' +updated: '2026-07-19T22:14:43Z' From 2d231808bf8bd92bd66d849d43d3b633de032f0b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Jul 2026 18:35:47 +0000 Subject: [PATCH 3/5] chore: auto-update manifest metadata [skip ci] --- apps/kilaueaactivity/manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kilaueaactivity/manifest.yaml b/apps/kilaueaactivity/manifest.yaml index 8586e272a..89d6c9b17 100644 --- a/apps/kilaueaactivity/manifest.yaml +++ b/apps/kilaueaactivity/manifest.yaml @@ -15,4 +15,4 @@ tags: - hawaii - volcano published: '2026-07-19T22:14:43Z' -updated: '2026-07-19T22:14:43Z' +updated: '2026-07-19T22:15:24Z' From aa459c0af2ff25f6d56aaa6f94fa12a56e0a6dab Mon Sep 17 00:00:00 2001 From: Tavis Date: Mon, 20 Jul 2026 12:01:51 -0700 Subject: [PATCH 4/5] =?UTF-8?q?Add=20minimum=20alert=20level=20filter=20to?= =?UTF-8?q?=20K=C4=ABlauea=20Activity.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide the app when the current USGS alert level is below the configured threshold. Co-authored-by: Cursor --- apps/kilaueaactivity/kilaueaactivity.star | 37 ++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/kilaueaactivity/kilaueaactivity.star b/apps/kilaueaactivity/kilaueaactivity.star index 9de787820..36e0d5cfd 100644 --- a/apps/kilaueaactivity/kilaueaactivity.star +++ b/apps/kilaueaactivity/kilaueaactivity.star @@ -62,6 +62,20 @@ ALERT_DIM_COLORS = { "RED": "#421015", } +LEVEL_ORDER = { + "normal": 0, + "advisory": 1, + "watch": 2, + "warning": 3, +} + +LEVELS = [ + schema.Option(display = "Normal", value = "normal"), + schema.Option(display = "Advisory", value = "advisory"), + schema.Option(display = "Watch", value = "watch"), + schema.Option(display = "Warning", value = "warning"), +] + MONTHS = [ ("JANUARY", "JAN"), ("FEBRUARY", "FEB"), @@ -147,6 +161,10 @@ def main(config): else: status = fetch_status() + min_level = config.get("min_level", "normal") + if not meets_min_level(status, min_level): + return [] + preview = config.get("preview") if preview == "scene": phase = int(config.get("preview_phase", "3")) % 6 @@ -602,8 +620,25 @@ def short_date(sent_utc): def alert_color(color_code): return ALERT_COLORS.get(color_code.upper(), COLOR_MUTED) +def meets_min_level(status, min_level): + current_level = LEVEL_ORDER.get(status.get("alert_level", "").lower(), 0) + threshold = LEVEL_ORDER.get(min_level, 0) + return current_level >= threshold + def get_schema(): - return schema.Schema(version = "1", fields = []) + return schema.Schema( + version = "1", + fields = [ + schema.Dropdown( + id = "min_level", + name = "Min Alert Level", + desc = "Only show if alert level is at or above this", + icon = "volcano", + options = LEVELS, + default = "normal", + ), + ], + ) def rect(x, y, width, height, color): return render.Padding( From b90ce882a9a573b0150ce57b9b2b44cd2354693e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Jul 2026 19:02:12 +0000 Subject: [PATCH 5/5] chore: auto-update manifest metadata [skip ci] --- apps/kilaueaactivity/manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kilaueaactivity/manifest.yaml b/apps/kilaueaactivity/manifest.yaml index 89d6c9b17..b54a77de7 100644 --- a/apps/kilaueaactivity/manifest.yaml +++ b/apps/kilaueaactivity/manifest.yaml @@ -15,4 +15,4 @@ tags: - hawaii - volcano published: '2026-07-19T22:14:43Z' -updated: '2026-07-19T22:15:24Z' +updated: '2026-07-20T19:01:51Z'