From 18eae75ead63bae12612b18db10ad36c63965c5b Mon Sep 17 00:00:00 2001 From: wurtz <4379111+wurtz@users.noreply.github.com> Date: Thu, 19 Mar 2026 06:15:29 -0400 Subject: [PATCH 1/2] Add SkyKnow app - rain & snow precipitation alerts Displays upcoming rain and snow events from Open-Meteo forecasts with timing, probability, and accumulation details. Co-Authored-By: Claude Sonnet 4.6 --- apps/skyknow/manifest.yaml | 15 + apps/skyknow/sky_know.star | 919 +++++++++++++++++++++++++++++++++++++ apps/skyknow/sky_know.webp | Bin 0 -> 2908 bytes 3 files changed, 934 insertions(+) create mode 100644 apps/skyknow/manifest.yaml create mode 100644 apps/skyknow/sky_know.star create mode 100644 apps/skyknow/sky_know.webp diff --git a/apps/skyknow/manifest.yaml b/apps/skyknow/manifest.yaml new file mode 100644 index 00000000..27be5dc7 --- /dev/null +++ b/apps/skyknow/manifest.yaml @@ -0,0 +1,15 @@ +--- +id: sky-know +name: SkyKnow +summary: Rain & snow alert display +desc: Monitors weather forecasts and displays alerts for incoming rain or snow with estimated timing, probability, and accumulation. Scrolls through multiple events chronologically. +author: wurtz +fileName: sky_know.star +packageName: skyknow +recommendedInterval: 5 +category: weather +tags: + - weather + - precipitation + - snow + - rain diff --git a/apps/skyknow/sky_know.star b/apps/skyknow/sky_know.star new file mode 100644 index 00000000..312e8b90 --- /dev/null +++ b/apps/skyknow/sky_know.star @@ -0,0 +1,919 @@ +load("cache.star", "cache") +load("encoding/base64.star", "base64") +load("encoding/json.star", "json") +load("http.star", "http") +load("render.star", "render") +load("schema.star", "schema") +load("time.star", "time") + +# WMO weather codes by precipitation type +SNOW_CODES = [71, 73, 75, 77, 85, 86] +RAIN_CODES = [51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82, 95, 96, 99] + +# Colors +SNOW_WHITE = "#FFFFFF" +DIM_GRAY = "#666666" +ERROR_RED = "#FF4444" + +# Severity colors: light → moderate → heavy +SEVERITY_SNOW = ["#4488FF", "#FFAA00", "#FF4444"] # blue, amber, red +SEVERITY_RAIN = ["#44AAFF", "#FFAA00", "#FF6633"] # cyan, amber, orange-red + +# Supporting text gray by severity (Lines 2-3) +GRAY_BY_SEVERITY = ["#777777", "#888888", "#999999"] # light, moderate, heavy + +CACHE_TTL = 1800 # 30 minutes + +# Animation settings +ANIM_FRAMES = 32 # frames per event (~3.2s at 100ms delay) +ANIM_DELAY = 100 # ms per frame + +# Particle colors (dim so they don't compete with text) +PARTICLE_SNOW = "#334466" +PARTICLE_RAIN = "#334455" + +# Particle data: (x, y, speed, phase) - phase for sine wave offset +SNOW_PARTICLES = [ + (5, 2, 0.5, 0), + (15, 22, 0.6, 2), + (28, 8, 0.4, 1), + (42, 28, 0.7, 3), + (55, 14, 0.5, 4), + (10, 18, 0.6, 5), + (35, 5, 0.4, 2), + (50, 25, 0.8, 1), + (20, 12, 0.5, 3), + (60, 20, 0.6, 0), + (8, 28, 0.7, 4), + (48, 10, 0.5, 2), +] +RAIN_PARTICLES = [ + (3, 1, 1.5, 0), + (12, 9, 1.8, 0), + (22, 17, 1.4, 0), + (30, 5, 2.0, 0), + (38, 25, 1.6, 0), + (46, 13, 1.9, 0), + (54, 21, 1.5, 0), + (8, 29, 1.7, 0), + (18, 7, 2.0, 0), + (33, 15, 1.8, 0), + (48, 3, 1.4, 0), + (58, 23, 1.6, 0), + (26, 11, 1.9, 0), + (41, 19, 1.5, 0), + (10, 25, 1.7, 0), + (52, 8, 2.0, 0), +] + +# Interval options: value is hours, forecast_days is API param +INTERVAL_OPTIONS = { + "24": 1, + "48": 2, + "72": 3, + "120": 5, + "168": 7, +} + +# ─── Pixel art icons (8x8 base64-encoded PNGs) ─── + +ICON_SNOW_LIGHT = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGklEQVR4nGNgQAL/gYABF/iPBPAqwik5mAEA2w4T7X2e/9EAAAAASUVORK5CYII=""" + +ICON_SNOW_MEDIUM = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJklEQVR4nGNgQAL/gYABF/iPBPAqwimJF6DrROGj243VLXhNIAQAAqw7xYlDpPsAAAAASUVORK5CYII=""" + +ICON_SNOW_HEAVY = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAKklEQVR4nGNggIJp06b9R8YMyABdEkURLkmsJuEE/4GAEBu7AC5BkhQCAMFiUXPU/Pu7AAAAAElFTkSuQmCC""" + +ICON_RAIN_LIGHT = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAIklEQVR4nGNgQAIpq/7/Z8AFQJIuHf//41REUAFBKwYQAACc3Bfd0qQO/gAAAABJRU5ErkJggg==""" + +ICON_RAIN_MEDIUM = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJUlEQVR4nGNgQAIuHf//M+ACIEkYxqmIaIBiCrqRGFZRz15cAABYzx6vpJUFKQAAAABJRU5ErkJggg==""" + +ICON_RAIN_HEAVY = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAKUlEQVR4nGNggIJp06b9R8YMyABdEkURLkmsJuEELh3//2OjiVdAkSkApxZTC1LzE2gAAAAASUVORK5CYII=""" + +# ─── Frog sprite (13x11 pixels) for All Clear screen ─── + +FROG_COLORS = { + "D": "#2B7A2B", # Dark green (body, head, outline) + "G": "#45A845", # Green (lighter body sides) + "Y": "#E2BB38", # Yellow (belly) + "K": "#1E5E1E", # Eyelid (heavy drooping lids) + "E": "#CCCC55", # Eye (exposed eye slit) + "P": "#111111", # Pupil dot + "T": "#3D9E3D", # Toe pads +} + +# Frog sprite states (13 chars wide × 11 rows) +FROG_IDLE = [ + "000DDDDDDD000", + "0KDDDDDDDDDK0", + "0KKDDDDDDDKK0", + "0EPEDDDDDEPE0", + "0DDDDDDDDDDD0", + "0DDDDDDDDDDD0", + "DGGYYYYYYYGGD", + "DGGYYYYYYYGGD", + "DGGYYYYYYYGGD", + "0DDDDDDDDDDD0", + "0TT00DDD00TT0", +] + +FROG_BLINK = [ + "000DDDDDDD000", + "0KDDDDDDDDDK0", + "0KKDDDDDDDKK0", + "0KKDDDDDDDKK0", # Lids shut + "0DDDDDDDDDDD0", + "0DDDDDDDDDDD0", + "DGGYYYYYYYGGD", + "DGGYYYYYYYGGD", + "DGGYYYYYYYGGD", + "0DDDDDDDDDDD0", + "0TT00DDD00TT0", +] + +FROG_PUFF = [ + "000DDDDDDD000", + "0KDDDDDDDDDK0", + "0KKDDDDDDDKK0", + "0EPEDDDDDEPE0", + "0DDDDDDDDDDD0", + "0DDYYYYYYYDD0", # Yellow creeps up + "DGGYYYYYYYGGD", + "DGGYYYYYYYGGD", + "DGGYYYYYYYGGD", + "0DDDDDDDDDDD0", + "0TT00DDD00TT0", +] + +def main(config): + # Test mode: inject fake data for development/testing + test_mode = config.get("test_mode") + if test_mode: + return handle_test_mode(test_mode) + + # Parse location from schema picker + location = config.get("location") + if not location: + return render_no_location() + + loc = json.decode(location) + lat = loc.get("lat") + lng = loc.get("lng") + tz = loc.get("timezone", "UTC") + + if not lat or not lng: + return render_no_location() + + # Parse forecast interval + interval_hours = int(config.get("interval", "48")) + forecast_days = INTERVAL_OPTIONS.get(str(interval_hours), 2) + + forecast = get_forecast(lat, lng, tz, forecast_days) + if not forecast: + return render_error() + + events = parse_precip_events(forecast, interval_hours) + + if not events: + return render_no_precip(interval_hours) + + return render_events(events) + +# ─── Test mode ─── + +def handle_test_mode(mode): + if mode == "snow_light": + forecast = fake_snow_light() + events = parse_precip_events(forecast, 48) + elif mode == "snow_moderate": + forecast = fake_snow_moderate() + events = parse_precip_events(forecast, 48) + elif mode == "snow_heavy": + forecast = fake_snow_heavy() + events = parse_precip_events(forecast, 48) + elif mode == "snow_now": + forecast = fake_snow_now() + events = parse_precip_events(forecast, 48) + elif mode == "rain_light": + forecast = fake_rain_light() + events = parse_precip_events(forecast, 48) + elif mode == "rain_moderate": + forecast = fake_rain_moderate() + events = parse_precip_events(forecast, 48) + elif mode == "rain_heavy": + forecast = fake_rain_heavy() + events = parse_precip_events(forecast, 48) + elif mode == "rain_now": + forecast = fake_rain_now() + events = parse_precip_events(forecast, 48) + elif mode == "mixed": + forecast = fake_mixed_forecast() + events = parse_precip_events(forecast, 72) + elif mode == "clear": + return render_no_precip(48) + elif mode == "error": + return render_error() + # Legacy test modes (backwards compatibility) + + elif mode == "snow": + forecast = fake_snow_moderate() + events = parse_precip_events(forecast, 48) + elif mode == "rain": + forecast = fake_rain_moderate() + events = parse_precip_events(forecast, 48) + else: + return render_error() + + if not events: + return render_no_precip(48) + return render_events(events) + +def fake_snow_light(): + """Light snow - 6h away, dusting, 40%""" + return { + "hourly": { + "time": ["2026-02-13T20:00", "2026-02-13T21:00", "2026-02-13T22:00"], + "snowfall": [0.2, 0.3, 0.2], + "rain": [0, 0, 0], + "precipitation_probability": [30, 40, 35], + "weathercode": [71, 71, 71], + }, + } + +def fake_snow_moderate(): + """Moderate snow - tomorrow, 3-6", 85%""" + return { + "hourly": { + "time": [ + "2026-02-14T14:00", + "2026-02-14T15:00", + "2026-02-14T16:00", + "2026-02-14T17:00", + "2026-02-14T18:00", + "2026-02-14T19:00", + ], + "snowfall": [0.8, 1.2, 1.5, 0.9, 0.6, 0.3], + "rain": [0, 0, 0, 0, 0, 0], + "precipitation_probability": [70, 80, 85, 80, 70, 60], + "weathercode": [73, 73, 75, 73, 73, 71], + }, + } + +def fake_snow_heavy(): + """Heavy snow - 12h away, 12"+, 95%""" + return { + "hourly": { + "time": [ + "2026-02-14T02:00", + "2026-02-14T03:00", + "2026-02-14T04:00", + "2026-02-14T05:00", + "2026-02-14T06:00", + "2026-02-14T07:00", + "2026-02-14T08:00", + "2026-02-14T09:00", + "2026-02-14T10:00", + ], + "snowfall": [1.5, 2.0, 2.5, 2.0, 1.8, 1.5, 1.2, 1.0, 0.8], + "rain": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "precipitation_probability": [90, 95, 95, 95, 95, 90, 85, 80, 75], + "weathercode": [75, 75, 75, 75, 75, 75, 73, 73, 73], + }, + } + +def fake_snow_now(): + """Snow happening now - ends at 8pm, 6-12", 100%""" + return { + "hourly": { + "time": [ + "2026-02-13T13:00", + "2026-02-13T14:00", + "2026-02-13T15:00", + "2026-02-13T16:00", + "2026-02-13T17:00", + "2026-02-13T18:00", + "2026-02-13T19:00", + "2026-02-13T20:00", + ], + "snowfall": [1.2, 1.5, 1.8, 1.5, 1.2, 0.9, 0.6, 0.3], + "rain": [0, 0, 0, 0, 0, 0, 0, 0], + "precipitation_probability": [100, 100, 100, 100, 95, 90, 80, 70], + "weathercode": [75, 75, 75, 75, 73, 73, 71, 71], + }, + } + +def fake_rain_light(): + """Light rain - 2d away, light, 25%""" + return { + "hourly": { + "time": ["2026-02-15T10:00", "2026-02-15T11:00", "2026-02-15T12:00"], + "snowfall": [0, 0, 0], + "rain": [0.1, 0.15, 0.1], + "precipitation_probability": [20, 25, 20], + "weathercode": [61, 61, 61], + }, + } + +def fake_rain_moderate(): + """Moderate rain - 18h away (tomorrow), moderate, 70%""" + return { + "hourly": { + "time": [ + "2026-02-14T08:00", + "2026-02-14T09:00", + "2026-02-14T10:00", + "2026-02-14T11:00", + "2026-02-14T12:00", + ], + "snowfall": [0, 0, 0, 0, 0], + "rain": [0.4, 0.6, 0.5, 0.4, 0.3], + "precipitation_probability": [60, 70, 70, 65, 55], + "weathercode": [63, 63, 63, 61, 61], + }, + } + +def fake_rain_heavy(): + """Heavy rain - 4h away (today), heavy, 90%""" + return { + "hourly": { + "time": [ + "2026-02-13T18:00", + "2026-02-13T19:00", + "2026-02-13T20:00", + "2026-02-13T21:00", + "2026-02-13T22:00", + "2026-02-13T23:00", + ], + "snowfall": [0, 0, 0, 0, 0, 0], + "rain": [0.8, 1.2, 1.5, 1.0, 0.8, 0.5], + "precipitation_probability": [85, 90, 90, 85, 80, 70], + "weathercode": [65, 65, 65, 63, 63, 61], + }, + } + +def fake_rain_now(): + """Rain happening now - ends at 3pm, heavy, 100%""" + return { + "hourly": { + "time": [ + "2026-02-13T11:00", + "2026-02-13T12:00", + "2026-02-13T13:00", + "2026-02-13T14:00", + "2026-02-13T15:00", + ], + "snowfall": [0, 0, 0, 0, 0], + "rain": [1.0, 1.2, 1.0, 0.8, 0.5], + "precipitation_probability": [100, 100, 100, 95, 85], + "weathercode": [65, 65, 65, 63, 63], + }, + } + +def fake_mixed_forecast(): + return { + "hourly": { + "time": [ + "2026-02-13T08:00", + "2026-02-13T09:00", + "2026-02-13T10:00", + "2026-02-13T11:00", + "2026-02-13T12:00", + "2026-02-13T13:00", + "2026-02-13T14:00", + "2026-02-13T15:00", + "2026-02-13T16:00", + # gap then snow the next day + "2026-02-14T02:00", + "2026-02-14T03:00", + "2026-02-14T04:00", + "2026-02-14T05:00", + "2026-02-14T06:00", + "2026-02-14T07:00", + "2026-02-14T08:00", + "2026-02-14T09:00", + "2026-02-14T10:00", + ], + "snowfall": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0.8, 1.5, 2.0, 1.2, 0.5, 0.3, 0.1, 0, 0], + "rain": [0.3, 0.8, 1.5, 2.0, 1.0, 0.5, 0.2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "precipitation_probability": [50, 65, 80, 85, 70, 55, 40, 10, 0, 60, 75, 90, 80, 65, 50, 30, 10, 0], + "weathercode": [61, 63, 65, 65, 63, 61, 51, 0, 0, 73, 75, 75, 73, 71, 71, 71, 0, 0], + }, + } + +# ─── API ─── + +def get_forecast(lat, lng, tz, forecast_days): + cache_key = "sky_know_%s_%s_%d" % (lat, lng, forecast_days) + cached = cache.get(cache_key) + if cached: + return json.decode(cached) + + url = ( + "https://api.open-meteo.com/v1/forecast" + + "?latitude=%s&longitude=%s" % (lat, lng) + + "&hourly=snowfall,rain,precipitation_probability,weathercode" + + "&temperature_unit=fahrenheit" + + "&timezone=%s" % tz + + "&forecast_days=%d" % forecast_days + ) + + resp = http.get(url) + if resp.status_code != 200: + return None + + data = resp.json() + cache.set(cache_key, json.encode(data), ttl_seconds = CACHE_TTL) + return data + +# ─── Event parsing ─── + +def parse_precip_events(forecast, hours_limit): + """Parse hourly data into discrete precipitation events.""" + hourly = forecast.get("hourly", {}) + times = hourly.get("time", []) + snowfall = hourly.get("snowfall", []) + rain = hourly.get("rain", []) + probs = hourly.get("precipitation_probability", []) + codes = hourly.get("weathercode", []) + + limit = hours_limit if len(times) > hours_limit else len(times) + + # Classify each hour + hours = [] + for i in range(limit): + code = codes[i] if i < len(codes) else 0 + sf = snowfall[i] if i < len(snowfall) else 0 + rf = rain[i] if i < len(rain) else 0 + prob = probs[i] if i < len(probs) else 0 + + precip_type = classify_hour(code, sf, rf) + if precip_type: + hours.append({ + "time": times[i], + "type": precip_type, + "snowfall": sf, + "rain": rf, + "probability": prob, + }) + + if not hours: + return [] + + # Group consecutive hours into events (allow 2-hour gaps) + events = [] + current = new_event(hours[0]) + + for i in range(1, len(hours)): + h = hours[i] + gap = hours_between(hours[i - 1]["time"], h["time"]) + + if h["type"] == current["type"] and gap <= 3: + extend_event(current, h) + else: + events.append(current) + current = new_event(h) + + events.append(current) + return events + +def classify_hour(code, snowfall, rain): + """Classify an hour as snow, rain, or None.""" + if code in SNOW_CODES or snowfall > 0: + return "snow" + if code in RAIN_CODES or rain > 0: + return "rain" + return None + +def new_event(hour): + return { + "type": hour["type"], + "start": hour["time"], + "end": hour["time"], + "max_prob": hour["probability"], + "total_snow": hour["snowfall"], + "total_rain": hour["rain"], + } + +def extend_event(event, hour): + event["end"] = hour["time"] + event["total_snow"] += hour["snowfall"] + event["total_rain"] += hour["rain"] + if hour["probability"] > event["max_prob"]: + event["max_prob"] = hour["probability"] + +def hours_between(time_a, time_b): + """Estimate hour gap between two ISO time strings.""" + ha = int(time_a.split("T")[1].split(":")[0]) + hb = int(time_b.split("T")[1].split(":")[0]) + da = time_a.split("T")[0] + db = time_b.split("T")[0] + + if da == db: + return hb - ha + + # Different days — approximate + return (24 - ha) + hb + +# ─── Rendering ─── + +def render_events(events): + """Render one or more precipitation events with particle animation.""" + frames = [] + for event in events: + frames.extend(render_event_frames(event)) + + return render.Root( + delay = ANIM_DELAY, + child = render.Animation( + children = frames, + ), + ) + +def render_event_frames(event): + """Generate animated frames for a single event with falling particles.""" + precip_type = event["type"] + text_content = render_event_text(event) + + particles = SNOW_PARTICLES if precip_type == "snow" else RAIN_PARTICLES + color = PARTICLE_SNOW if precip_type == "snow" else PARTICLE_RAIN + + frames = [] + for i in range(ANIM_FRAMES): + particle_layer = render_particles(particles, color, i, precip_type) + frames.append( + render.Stack( + children = [ + particle_layer, + text_content, + ], + ), + ) + return frames + +def render_particles(particles, color, frame, precip_type): + """Render a single frame of falling particles with variable speeds.""" + children = [] + for p in particles: + base_x = p[0] + base_y = p[1] + speed = p[2] + phase = p[3] + + # Calculate Y position with individual speed + y = int((base_y + frame * speed)) % 32 + + # Snow drifts with sine wave motion + if precip_type == "snow": + # Sine wave approximation using modulo (period ~16 frames) + wave = ((frame + phase * 4) % 16) - 8 # Range: -8 to +7 + if wave < -4: + drift = -1 + elif wave > 4: + drift = 1 + else: + drift = 0 + x = (base_x + drift) % 64 + else: + # Rain falls straight down + x = base_x + + # Snow: 1x1 dot, Rain: 1x2 streak + h = 1 if precip_type == "snow" else 2 + + children.append( + render.Padding( + pad = (x, y, 0, 0), + child = render.Box(width = 1, height = h, color = color), + ), + ) + + return render.Stack(children = children) + +def render_event_text(event): + """Render the static text overlay for an event (3-line hero countdown layout).""" + precip_type = event["type"] + accum = event["total_snow"] if precip_type == "snow" else event["total_rain"] + severity = get_severity(precip_type, accum) + icon = get_icon(precip_type, severity) + header_color = get_severity_color(precip_type, severity) + gray_color = GRAY_BY_SEVERITY[severity] + label = "SNOW" if precip_type == "snow" else "RAIN" + duration = format_duration(event["start"], event["end"]) + day_label = format_day_label(event["start"], event["end"], duration) + prob_str = "%d%%" % event["max_prob"] + accum_str = format_snow_accum(accum) if precip_type == "snow" else format_rain_accum(accum) + + return render.Column( + expanded = True, + main_align = "space_evenly", + children = [ + # Line 1: Icon + TYPE + Countdown (severity colored) + render.Row( + expanded = True, + main_align = "center", + cross_align = "center", + children = [ + render.Image(src = base64.decode(icon), width = 8, height = 8), + render.Box(width = 2, height = 1), + render.Text("%s %s" % (label, duration), color = header_color, font = "tom-thumb"), + ], + ), + # Line 2: Day label (dimmed gray) + render.Row( + expanded = True, + main_align = "center", + children = [ + render.Text(day_label, color = gray_color, font = "tom-thumb"), + ], + ), + # Line 3: Prob + Accum (dimmed gray) + render.Row( + expanded = True, + main_align = "center", + children = [ + render.Text("%s %s" % (prob_str, accum_str), color = gray_color, font = "tom-thumb"), + ], + ), + ], + ) + +def render_frog_sprite(sprite_data): + """Render a single frog sprite frame from string data.""" + children = [] + for y in range(len(sprite_data)): + row = sprite_data[y] + for x in range(len(row)): + char = row[x] + if char != "0": # 0 = transparent + color = FROG_COLORS.get(char, "#000000") + children.append( + render.Padding( + pad = (x, y, 0, 0), + child = render.Box(width = 1, height = 1, color = color), + ), + ) + return render.Stack(children = children) + +def render_frog_animation(): + """Generate animated frog frames (idle → blink → idle → puff → idle).""" + frames = [] + + # Frames 0-49: Idle (5.0s) + for _ in range(50): + frames.append(render_frog_sprite(FROG_IDLE)) + + # Frames 50-53: Blink (0.4s) + for _ in range(4): + frames.append(render_frog_sprite(FROG_BLINK)) + + # Frames 54-73: Idle (2.0s) + for _ in range(20): + frames.append(render_frog_sprite(FROG_IDLE)) + + # Frames 74-79: Throat Puff (0.6s) + for _ in range(6): + frames.append(render_frog_sprite(FROG_PUFF)) + + # Frames 80-89: Idle (1.0s) + for _ in range(10): + frames.append(render_frog_sprite(FROG_IDLE)) + + return frames + +def render_no_precip(interval_hours): + """Render All Clear screen with animated frog.""" + if interval_hours >= 24: + days = interval_hours // 24 + label = "NEXT %dD" % days + else: + label = "NEXT %dHR" % interval_hours + + frog_frames = render_frog_animation() + full_frames = [] + + for frog in frog_frames: + full_frames.append( + render.Column( + expanded = True, + main_align = "center", + cross_align = "center", + children = [ + render.Box(height = 2, width = 1), # Top spacer + render.Row( + expanded = True, + main_align = "center", + children = [frog], + ), + render.Box(height = 2, width = 1), # Spacer between frog and text + render.Text("ALL CLEAR", color = "#445566", font = "tom-thumb"), + render.Text(label, color = "#334455", font = "tom-thumb"), + ], + ), + ) + + return render.Root( + delay = 100, + child = render.Animation( + children = full_frames, + ), + ) + +def render_no_location(): + return render.Root( + child = render.Column( + expanded = True, + main_align = "center", + cross_align = "center", + children = [ + render.Text("SKY KNOW", color = SEVERITY_SNOW[0], font = "tom-thumb"), + render.Text("SET LOCATION", color = DIM_GRAY, font = "tom-thumb"), + ], + ), + ) + +def render_error(): + return render.Root( + child = render.Column( + expanded = True, + main_align = "center", + cross_align = "center", + children = [ + render.Text("SKY KNOW", color = ERROR_RED, font = "tom-thumb"), + render.Text("API ERROR", color = ERROR_RED, font = "tom-thumb"), + ], + ), + ) + +# ─── Icons ─── + +def get_severity(precip_type, accum): + """Return severity level 0 (light), 1 (moderate), 2 (heavy).""" + if precip_type == "snow": + if accum < 1: + return 0 + elif accum < 4: + return 1 + else: + return 2 + elif accum < 0.25: + return 0 + elif accum < 1: + return 1 + else: + return 2 + +def get_severity_color(precip_type, severity): + """Get color based on precip type and severity level.""" + if precip_type == "snow": + return SEVERITY_SNOW[severity] + return SEVERITY_RAIN[severity] + +def get_icon(precip_type, severity): + """Pick the right icon based on precip type and severity.""" + if precip_type == "snow": + return [ICON_SNOW_LIGHT, ICON_SNOW_MEDIUM, ICON_SNOW_HEAVY][severity] + return [ICON_RAIN_LIGHT, ICON_RAIN_MEDIUM, ICON_RAIN_HEAVY][severity] + +# ─── Formatting helpers ─── + +def format_duration(start_str, end_str): + """Format event duration like 'NOW', '2h', '1d3h' based on event length.""" + now = time.now() + start = time.parse_time(start_str.replace("T", " "), format = "2006-01-02 15:04") + end = time.parse_time(end_str.replace("T", " "), format = "2006-01-02 15:04") + + # Check if event is happening now + if start <= now and end >= now: + return "NOW" + + # Calculate duration of event + duration = end - start + hours = int(duration.hours) + + if hours < 1: + return "1h" # Minimum 1 hour + elif hours < 24: + return "%dh" % hours + else: + days = hours // 24 + remaining_hours = hours % 24 + if remaining_hours > 0: + return "%dd%dh" % (days, remaining_hours) + return "%dd" % days + +def format_day_label(start_str, end_str, duration): + """Format smart day label: Today/Tonight/Tomorrow/Wed or 'Thru 8pm' for NOW.""" + if duration == "NOW": + # Show end time for currently happening events + end_parts = end_str.split("T") + end_hour = int(end_parts[1].split(":")[0]) + return "Thru %s" % format_hour(end_hour) + + now = time.now() + start = time.parse_time(start_str.replace("T", " "), format = "2006-01-02 15:04") + + diff = start - now + hours = int(diff.hours) + + # Parse start hour to determine if it's "tonight" + start_parts = start_str.split("T") + start_hour = int(start_parts[1].split(":")[0]) + + if hours < 12: + # Within 12 hours + if start_hour >= 18 or start_hour < 6: + return "Tonight" + return "Today" + elif hours < 36: + # Tomorrow + return "Tomorrow" + else: + # 2+ days out, use day abbreviation + return start.format("Mon") + +def format_time_window(start_str, end_str): + start_parts = start_str.split("T") + end_parts = end_str.split("T") + + start_date = start_parts[0] + start_hour = int(start_parts[1].split(":")[0]) + end_date = end_parts[0] + end_hour = int(end_parts[1].split(":")[0]) + + start_day = get_day_abbr(start_date) + end_day = get_day_abbr(end_date) + + start_fmt = "%s %s" % (start_day, format_hour(start_hour)) + end_fmt = "%s %s" % (end_day, format_hour(end_hour)) + + return "%s-%s" % (start_fmt, end_fmt) + +def format_hour(h): + if h == 0: + return "12am" + elif h < 12: + return "%dam" % h + elif h == 12: + return "12pm" + else: + return "%dpm" % (h - 12) + +def get_day_abbr(date_str): + t = time.parse_time(date_str + "T00:00:00Z", format = "2006-01-02T15:04:05Z") + return t.format("Mon") + +def format_snow_accum(total): + if total < 0.5: + return "Dusting" + elif total < 1: + return "<1\"" + elif total < 3: + return "1-3\"" + elif total < 6: + return "3-6\"" + elif total < 12: + return "6-12\"" + else: + return "12\"+" + +def format_rain_accum(total): + if total < 0.1: + return "Trace" + elif total < 0.25: + return "Light" + elif total < 0.5: + return "Moderate" + elif total < 1: + return "Heavy" + else: + whole = int(total) + frac = int((total - whole) * 10) + return "%d.%d\"" % (whole, frac) + +# ─── Schema ─── + +def get_schema(): + return schema.Schema( + version = "1", + fields = [ + schema.Location( + id = "location", + name = "Location", + desc = "Location for weather forecast", + icon = "locationDot", + ), + schema.Dropdown( + id = "interval", + name = "Forecast Window", + desc = "How far ahead to check for precipitation", + icon = "clock", + default = "48", + options = [ + schema.Option(display = "24 hours", value = "24"), + schema.Option(display = "48 hours", value = "48"), + schema.Option(display = "3 days", value = "72"), + schema.Option(display = "5 days", value = "120"), + schema.Option(display = "7 days", value = "168"), + ], + ), + ], + ) diff --git a/apps/skyknow/sky_know.webp b/apps/skyknow/sky_know.webp new file mode 100644 index 0000000000000000000000000000000000000000..6a81bb73aff6244c64ea524c0a559e100eaff345 GIT binary patch literal 2908 zcmZ`*4LFqP8h&RCX2SZZFocPjMr{nnZ`6!2n8Bd*V@oZa677zP)+Sj)>9nGZ6y+3! zwzh3oL@PtnAC*q0QCnN8%~=(8sl{H(-|X{!ll9HjIqzkz%llp5dq2-}KlgLL-x6_9 z&{6}0Rs;$nmq&U>PeTZq!HE?)UdFfBp;{zo0&3+^9c%&oVyaf&qp4=zCU)8clG+>dOW5`5dA65u&Asy2)NYf)Ohi1F6F-e4GJxb8OU-xA>X`o7b8)` zRxLJ~LCrn2CpG4ga^nZ>Wcss7 z$G5-!(N?7{3dFdGfj7tqkr47i$a?Ce5IgCm)FYe>lrue2y4Yp~$?bRi=o6;nD>~|p z{vl26wZ3B6x?+CwEX%0qitzj04YPWM^=lQf@QqT1DHa36(m{xV5DOvmsTlLI7%Z7C zAx0I6@8YSVO=PAl^EjcNMke#G(G#ZE>l!<@o}*z{v2aF{uQx)orm%uBEN`Swz^ap1 zdUAd!Fmud_T(4?Wv^k9MNF^TJw3i3sTT*wNITJ16E~qVv-xePeA4{T>%efZ@!kp@( ztZ+tG0v;p`f9^P`>tK&D>W{^7R}ypfkTd5Fj+GtVbzorP3)dH>;}&Mj62%ok39T(P zhgV2@O6SI%u08j!h&eM{Y>Z`nU3z$grGNyLz~bUMos8fMPdHp9v62=!vo6su&eZDW zPQ^3Radzo>*Qg%0eR<IG4B>~C+VN7Dr!?4|C zWCBY$pELbyLpM`j8IKqUhcd~h&cwG=9HRWNjEf_^*T)B027H*MhVMvL@0e`=T?RExpf9ZUvE8MpP{#Js|FcE(r~O% z%MI9LT@1sJwbc?NW%~3QSsc@eD>ppf*?c_7*dO5ejk8MvJ}Ei*MQ82Rql$Hj*JyoR ztTH`u@*+_oPftuXI9(_e4=lv0NFc_tnW`cJkHsfe5hQgGf7>+dV`3f>@ENtOVSj=i z9&CEPBJShe?M-iMZnD$F?+!G4mCedeVE-Au(II@f`+(Vx^3_&O;%7QIYtfo`FuA}J zeJUP!8shlyiIa;d^gjhnlo>F&qYDFI_-U~fxsnA}e6u`Ov+F${9(KO{{5BJQ`u<^x zry6{00$^%2&On$nd;+|*ChfYsQuu6yNfFF;&%V$#WT(ok5nFQ)ezoMjT()=9vb(n; zvd|`m;n2l}4y4BZ*?EJMzbk;gLxViDyoZlf;GyL`*aXo1A_R;ggTBI7)$P6~)YE0I z1lNg9vhkNrUC8rCk?z z!g1Bou18WwP)PgWa7Nw9@)S(E0cD--F^k?dthrh>c-3Y%3CsC?SAlq#T+^wp@-aL& zKGDgS%PU(}ik|&Jr^&P3uW^cZ?wN={HTvdsXTRhk);G6B-3^K^cQ0ojR_Px+i?S!W z7=8%g05~k*oG@$H4Fa4KReD|OF!z&-jT7ndV?kgH%0!M$>&!PtDk3-LsEa@9OBmiq z{y_HxM2+sX5Q9L6-~O7uZ`VP$_7>my27ACF?rzIoPI^$Yf#e=FO?i0i?amfnJG1W4 zgNpUnD_(^4J9=yn-CH-`(W$Cx4GCu)CK>7Dx^%#=`AN5c(1_z8BQoTcSekqK`H1bP z!${eH{iwzh*1&szdjcWfAnd%iR=(Jc7y2^ItRUN6@hL-%Um#n#wzAr>!jdR^tHBW6 z{UzUV%ksF(dUF@V=Vip-juCOCI4c4`18svez;ofB$t-aPo`*;T2M&~4iw~zCGl+XP z_tq%3SX~PwWx4K#QvXrH8-ACzW9{z`^D7cEEB8C99y`f%TUtFBWA0}PgtQt zX*{p$s-dc+3iF1U!mW{q>zOtoYj4V$HN0S+2#LZ17yf7}W7vTU%XA1C=M*q~Qi7|g z|J?qj(}9oKbXamG`axZ1c%A|}SQ_fiA@|f|bJI99?8hMJ5sM7$Vjs&Ab5yAxkGLOE;}7!Sw=D>`d%QY5`k- zWr=?2_s)FkU|2bt+Nklk_YMIb2l>Ir(qxx#hv3SH9tzqGoR@S=#J}aY;(;hy^@PCy z4^p-sRR7#2`G&Erx= Date: Thu, 19 Mar 2026 10:43:25 -0400 Subject: [PATCH 2/2] Address Gemini review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract 6 base64 icons to images/ as PNG files per style guide - Remove unused format_time_window function - Simplify interval config: avoid redundant int→str conversion Co-Authored-By: Claude Sonnet 4.6 --- apps/skyknow/images/icon_rain_heavy.png | Bin 0 -> 98 bytes apps/skyknow/images/icon_rain_light.png | Bin 0 -> 91 bytes apps/skyknow/images/icon_rain_medium.png | Bin 0 -> 94 bytes apps/skyknow/images/icon_snow_heavy.png | Bin 0 -> 99 bytes apps/skyknow/images/icon_snow_light.png | Bin 0 -> 83 bytes apps/skyknow/images/icon_snow_medium.png | Bin 0 -> 95 bytes apps/skyknow/sky_know.star | 51 ++++++++--------------- 7 files changed, 17 insertions(+), 34 deletions(-) create mode 100644 apps/skyknow/images/icon_rain_heavy.png create mode 100644 apps/skyknow/images/icon_rain_light.png create mode 100644 apps/skyknow/images/icon_rain_medium.png create mode 100644 apps/skyknow/images/icon_snow_heavy.png create mode 100644 apps/skyknow/images/icon_snow_light.png create mode 100644 apps/skyknow/images/icon_snow_medium.png diff --git a/apps/skyknow/images/icon_rain_heavy.png b/apps/skyknow/images/icon_rain_heavy.png new file mode 100644 index 0000000000000000000000000000000000000000..67a924a34b8510101cf2a0f245e8d2e7493dfa0f GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqnw~C>Ar*6y6B?Q_FE9J+evIb? vL#&Xipq}R%wTCQvvj6`lFYXL?n5fCHTr8M7=(BJJP!ofvtDnm{r-UW|oaYzopr0KK0bOaK4? literal 0 HcmV?d00001 diff --git a/apps/skyknow/images/icon_snow_light.png b/apps/skyknow/images/icon_snow_light.png new file mode 100644 index 0000000000000000000000000000000000000000..07e8bf8c2c8d7545c7893e7f690897b412e958d3 GIT binary patch literal 83 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqQl2i3Ar*6y6C9ZSH#RVe|LAA= gpmj*oat0&AZ9d_*we$X81gc{2boFyt=akR{0QZO%tpET3 literal 0 HcmV?d00001 diff --git a/apps/skyknow/images/icon_snow_medium.png b/apps/skyknow/images/icon_snow_medium.png new file mode 100644 index 0000000000000000000000000000000000000000..495ed767e59bed6c23b8cb224fb0372491a86ac7 GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqYMw5RAr*6y6C9ZSH#RVe|LAA= spmj*IQ+&Z|mxqgQ_g>Yl@Ks=8U|M5+w9|RXZ=enaPgg&ebxsLQ0A30lasU7T literal 0 HcmV?d00001 diff --git a/apps/skyknow/sky_know.star b/apps/skyknow/sky_know.star index 312e8b90..18aea465 100644 --- a/apps/skyknow/sky_know.star +++ b/apps/skyknow/sky_know.star @@ -1,7 +1,12 @@ load("cache.star", "cache") -load("encoding/base64.star", "base64") load("encoding/json.star", "json") load("http.star", "http") +load("images/icon_rain_heavy.png", ICON_RAIN_HEAVY_ASSET = "file") +load("images/icon_rain_light.png", ICON_RAIN_LIGHT_ASSET = "file") +load("images/icon_rain_medium.png", ICON_RAIN_MEDIUM_ASSET = "file") +load("images/icon_snow_heavy.png", ICON_SNOW_HEAVY_ASSET = "file") +load("images/icon_snow_light.png", ICON_SNOW_LIGHT_ASSET = "file") +load("images/icon_snow_medium.png", ICON_SNOW_MEDIUM_ASSET = "file") load("render.star", "render") load("schema.star", "schema") load("time.star", "time") @@ -75,19 +80,13 @@ INTERVAL_OPTIONS = { "168": 7, } -# ─── Pixel art icons (8x8 base64-encoded PNGs) ─── - -ICON_SNOW_LIGHT = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGklEQVR4nGNgQAL/gYABF/iPBPAqwik5mAEA2w4T7X2e/9EAAAAASUVORK5CYII=""" - -ICON_SNOW_MEDIUM = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJklEQVR4nGNgQAL/gYABF/iPBPAqwimJF6DrROGj243VLXhNIAQAAqw7xYlDpPsAAAAASUVORK5CYII=""" - -ICON_SNOW_HEAVY = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAKklEQVR4nGNggIJp06b9R8YMyABdEkURLkmsJuEE/4GAEBu7AC5BkhQCAMFiUXPU/Pu7AAAAAElFTkSuQmCC""" - -ICON_RAIN_LIGHT = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAIklEQVR4nGNgQAIpq/7/Z8AFQJIuHf//41REUAFBKwYQAACc3Bfd0qQO/gAAAABJRU5ErkJggg==""" - -ICON_RAIN_MEDIUM = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJUlEQVR4nGNgQAIuHf//M+ACIEkYxqmIaIBiCrqRGFZRz15cAABYzx6vpJUFKQAAAABJRU5ErkJggg==""" - -ICON_RAIN_HEAVY = """iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAKUlEQVR4nGNggIJp06b9R8YMyABdEkURLkmsJuEELh3//2OjiVdAkSkApxZTC1LzE2gAAAAASUVORK5CYII=""" +# ─── Pixel art icons (8x8 PNGs) ─── +ICON_SNOW_LIGHT = ICON_SNOW_LIGHT_ASSET.readall() +ICON_SNOW_MEDIUM = ICON_SNOW_MEDIUM_ASSET.readall() +ICON_SNOW_HEAVY = ICON_SNOW_HEAVY_ASSET.readall() +ICON_RAIN_LIGHT = ICON_RAIN_LIGHT_ASSET.readall() +ICON_RAIN_MEDIUM = ICON_RAIN_MEDIUM_ASSET.readall() +ICON_RAIN_HEAVY = ICON_RAIN_HEAVY_ASSET.readall() # ─── Frog sprite (13x11 pixels) for All Clear screen ─── @@ -164,8 +163,9 @@ def main(config): return render_no_location() # Parse forecast interval - interval_hours = int(config.get("interval", "48")) - forecast_days = INTERVAL_OPTIONS.get(str(interval_hours), 2) + interval_str = config.get("interval", "48") + interval_hours = int(interval_str) + forecast_days = INTERVAL_OPTIONS.get(interval_str, 2) forecast = get_forecast(lat, lng, tz, forecast_days) if not forecast: @@ -612,7 +612,7 @@ def render_event_text(event): main_align = "center", cross_align = "center", children = [ - render.Image(src = base64.decode(icon), width = 8, height = 8), + render.Image(src = icon, width = 8, height = 8), render.Box(width = 2, height = 1), render.Text("%s %s" % (label, duration), color = header_color, font = "tom-thumb"), ], @@ -830,23 +830,6 @@ def format_day_label(start_str, end_str, duration): # 2+ days out, use day abbreviation return start.format("Mon") -def format_time_window(start_str, end_str): - start_parts = start_str.split("T") - end_parts = end_str.split("T") - - start_date = start_parts[0] - start_hour = int(start_parts[1].split(":")[0]) - end_date = end_parts[0] - end_hour = int(end_parts[1].split(":")[0]) - - start_day = get_day_abbr(start_date) - end_day = get_day_abbr(end_date) - - start_fmt = "%s %s" % (start_day, format_hour(start_hour)) - end_fmt = "%s %s" % (end_day, format_hour(end_hour)) - - return "%s-%s" % (start_fmt, end_fmt) - def format_hour(h): if h == 0: return "12am"