diff --git a/lib/name_badge/screen/weather.ex b/lib/name_badge/screen/weather.ex index a21cd4f..68d9816 100644 --- a/lib/name_badge/screen/weather.ex +++ b/lib/name_badge/screen/weather.ex @@ -1,6 +1,12 @@ defmodule NameBadge.Screen.Weather do @moduledoc """ - Weather screen that displays current weather information. + Weather screen with two views: + - Current: today's weather with date, location, temperature, condition, wind. + - Forecast: 7-day columnar forecast showing day names, temperatures, conditions, and wind. + + Button A: Refresh weather data. + Button B: Toggle between Current and Forecast views. + Long press B: Navigate back (handled by Screen behaviour). """ use NameBadge.Screen @@ -18,9 +24,9 @@ defmodule NameBadge.Screen.Weather do #align(center + horizon)[ #text(size: 24pt)[Loading weather data...] - + #text(size: 12pt)[ - Thanks to Tim Pritlove for contributing this screen! + Thanks to Tim Pritlove and Pepe for contributing this screen! ] ] """ @@ -43,23 +49,33 @@ defmodule NameBadge.Screen.Weather do """ end + def render(%{view: :forecast} = assigns), do: render_forecast(assigns) + def render(%{weather: weather, location: location}) do temp_display = format_temperature(weather.temperature, weather.temperature_unit) condition = weather_condition_text(weather.weather_code, weather.is_day) + symbol = weather_symbol(weather.weather_code, weather.is_day) wind_display = format_wind_speed(weather.wind_speed, weather.wind_speed_unit) + date_display = format_current_date() """ #show heading: set text(font: "Silkscreen", size: 36pt, weight: 400, tracking: -4pt) = Weather - #v(16pt) + #v(12pt) #align(center)[ - #stack(dir: ttb, spacing: 14pt, + #stack(dir: ttb, spacing: 12pt, + + // Date + text(size: 16pt, weight: 600)[#{date_display}], // Location - text(size: 16pt, style: "italic")[#{location || "Unknown Location"}], + text(size: 14pt, style: "italic")[#{location || "Unknown Location"}], + + // Weather symbol (using Unicode-compatible font) + text(size: 48pt, font: "DejaVu Sans")[#{symbol}], // Temperature (main display) text(size: 48pt, weight: 600)[#{temp_display}], @@ -79,21 +95,34 @@ defmodule NameBadge.Screen.Weather do @impl NameBadge.Screen def mount(_args, screen) do - # Get initial weather data and location + # Get initial weather data, forecast and location weather = NameBadge.Weather.get_current_weather() + forecast = NameBadge.Weather.get_forecast() location = NameBadge.Weather.get_location_name() screen = case weather do nil -> screen - |> assign(weather: nil, loading: true, location: location) - |> assign(button_hints: %{a: "Refresh"}) + |> assign( + weather: nil, + loading: true, + location: location, + forecast: forecast, + view: :current + ) + |> assign(button_hints: %{a: "Refresh", b: "Forecast"}) weather_data -> screen - |> assign(weather: weather_data, loading: false, location: location) - |> assign(button_hints: %{a: "Refresh"}) + |> assign( + weather: weather_data, + loading: false, + location: location, + forecast: forecast, + view: :current + ) + |> assign(button_hints: %{a: "Refresh", b: "Forecast"}) end {:ok, screen} @@ -108,7 +137,8 @@ defmodule NameBadge.Screen.Weather do # Show loading state screen = screen - |> assign(weather: nil, loading: true, error: nil) + |> assign(weather: nil, loading: true, error: nil, view: :current) + |> assign(button_hints: %{a: "Refresh", b: "Forecast"}) # Schedule a check for updated data in 2 seconds Process.send_after(self(), :check_weather_update, 2_000) @@ -117,8 +147,24 @@ defmodule NameBadge.Screen.Weather do end def handle_button(:button_2, :single_press, screen) do - # Button B does nothing - navigation back is handled by long press on button 2 - {:noreply, screen} + # Toggle between current weather and 7-day forecast views + case screen.assigns.view do + :current -> + screen = + screen + |> assign(view: :forecast) + |> assign(button_hints: %{a: "Refresh", b: "Current"}) + + {:noreply, screen} + + :forecast -> + screen = + screen + |> assign(view: :current) + |> assign(button_hints: %{a: "Refresh", b: "Forecast"}) + + {:noreply, screen} + end end def handle_button(_, _, screen), do: {:noreply, screen} @@ -126,6 +172,7 @@ defmodule NameBadge.Screen.Weather do @impl NameBadge.Screen def handle_info(:check_weather_update, screen) do weather = NameBadge.Weather.get_current_weather() + forecast = NameBadge.Weather.get_forecast() location = NameBadge.Weather.get_location_name() screen = @@ -135,17 +182,119 @@ defmodule NameBadge.Screen.Weather do weather: nil, loading: false, error: "Unable to fetch weather data", - location: location + location: location, + forecast: forecast ) weather_data -> - assign(screen, weather: weather_data, loading: false, error: nil, location: location) + assign(screen, + weather: weather_data, + loading: false, + error: nil, + location: location, + forecast: forecast + ) end {:noreply, screen} end - # Private helper functions + # ── Forecast view ────────────────────────────────────────────────────── + + defp render_forecast(%{forecast: forecast, location: location}) when is_list(forecast) do + date_range = format_date_range(forecast) + + # Row 1: Day name abbreviations (Mo, Tu, We, ...) + day_names = + forecast + |> Enum.map(fn day -> "[#text(size: 16pt, weight: 700)[#{format_day_abbr(day.date)}]]" end) + |> Enum.join(", ") + + # Row 2: Temperatures (min/max) + temps = + forecast + |> Enum.map(fn day -> + temp_unit = day.temperature_unit + + "[#text(size: 12pt)[#{round(day.min_temp)}#{temp_unit}] #linebreak() #text(size: 12pt)[#{round(day.max_temp)}#{temp_unit}]]" + end) + |> Enum.join(", ") + + # Row 3: Weather symbols (using Unicode-compatible font) + conditions = + forecast + |> Enum.map(fn day -> + "[#text(size: 32pt, font: \"DejaVu Sans\")[#{weather_symbol(day.weather_code, true)}]]" + end) + |> Enum.join(", ") + + # Row 4: Wind speeds (number+unit no space, single row) + winds = + forecast + |> Enum.map(fn day -> + "[#text(size: 9pt)[#{round(day.max_wind)}#{day.wind_speed_unit}]]" + end) + |> Enum.join(", ") + + """ + #show heading: set text(font: "Silkscreen", size: 36pt, weight: 400, tracking: -4pt) + + = Forecast + + #v(2pt) + + #align(center)[ + #text(size: 14pt, style: "italic")[#{location || "Unknown Location"}] + #v(-4pt) + #text(size: 12pt)[#{date_range}] + ] + + #v(2pt) + + #table( + columns: (1fr,) * 7, + align: center, + inset: 5pt, + stroke: 0.5pt + gray, + #{day_names}, + #{temps}, + #{conditions}, + #{winds} + ) + """ + end + + # ── Date helpers ─────────────────────────────────────────────────────── + + defp format_current_date do + Date.utc_today() + |> Calendar.strftime("%A, %B %d") + end + + defp format_date_range([]) do + "" + end + + defp format_date_range(forecast) do + first = List.first(forecast) + last = List.last(forecast) + + first_date = Date.from_iso8601!(first.date) + last_date = Date.from_iso8601!(last.date) + + if first_date.month == last_date.month do + "#{first_date.day}-#{last_date.day} #{Calendar.strftime(first_date, "%B")}" + else + "#{Calendar.strftime(first_date, "%b %d")} - #{Calendar.strftime(last_date, "%b %d")}" + end + end + + defp format_day_abbr(date_string) when is_binary(date_string) do + date = Date.from_iso8601!(date_string) + Calendar.strftime(date, "%a") + end + + # ── Temperature / wind formatting ───────────────────────────────────── defp format_temperature(temp, unit) when is_number(temp) and is_binary(unit) do "#{round(temp)}#{unit}" @@ -186,6 +335,59 @@ defmodule NameBadge.Screen.Weather do defp format_last_updated(_), do: "Recently" + # ── Weather symbols ─────────────────────────────────────────────────── + + defp weather_symbol(code, is_day) when is_number(code) do + case code do + # Clear sky + 0 -> if is_day, do: "☀", else: "☾" + # Mainly clear + 1 -> if is_day, do: "☀", else: "☾" + # Partly cloudy + 2 -> "☁" + # Overcast + 3 -> "☁" + # Fog + 45 -> "≋" + 48 -> "≋" + # Drizzle + 51 -> "∴" + 53 -> "∴" + 55 -> "∴" + # Freezing drizzle + 56 -> "❄" + 57 -> "❄" + # Rain + 61 -> "☂" + 63 -> "☂" + 65 -> "☂" + # Freezing rain + 66 -> "❄" + 67 -> "❄" + # Snow + 71 -> "❄" + 73 -> "❄" + 75 -> "❄" + 77 -> "❄" + # Rain showers + 80 -> "☂" + 81 -> "☂" + 82 -> "☂" + # Snow showers + 85 -> "❄" + 86 -> "❄" + # Thunderstorm + 95 -> "⚡" + 96 -> "⚡" + 99 -> "⚡" + _ -> "?" + end + end + + defp weather_symbol(_code, _is_day), do: "?" + + # ── Weather condition texts ─────────────────────────────────────────── + defp weather_condition_text(code, is_day) when is_number(code) do case code do 0 -> "Clear sky" diff --git a/lib/name_badge/weather.ex b/lib/name_badge/weather.ex index 3bab419..6dc8610 100644 --- a/lib/name_badge/weather.ex +++ b/lib/name_badge/weather.ex @@ -12,6 +12,7 @@ defmodule NameBadge.Weather do :longitude, :location_source, :weather_data, + :forecast_data, :last_updated, :timer, :failure_count, @@ -53,6 +54,24 @@ defmodule NameBadge.Weather do end end + @doc """ + Get 7-day daily forecast data. Returns nil if not available. + Each entry is a map with keys: :date, :weather_code, :max_temp, :min_temp, :max_wind. + """ + def get_forecast do + try do + GenServer.call(__MODULE__, :get_forecast, @call_timeout) + catch + :exit, {:timeout, _} -> + Logger.warning("Weather service forecast call timed out") + nil + + :exit, {:noproc, _} -> + Logger.warning("Weather service not available for forecast") + nil + end + end + @doc """ Force a weather update (useful for refresh button) """ @@ -102,6 +121,11 @@ defmodule NameBadge.Weather do {:reply, state.weather_data, state} end + @impl GenServer + def handle_call(:get_forecast, _from, state) do + {:reply, state.forecast_data, state} + end + @impl GenServer def handle_call(:get_location_name, _from, state) do {:reply, state.location_source, state} @@ -175,12 +199,12 @@ defmodule NameBadge.Weather do defp update_weather(state) do case fetch_weather(state.latitude, state.longitude) do - {:ok, weather} -> - Logger.debug("Weather updated successfully") + {:ok, weather, forecast} -> %{ state | weather_data: weather, + forecast_data: forecast, last_updated: DateTime.utc_now(), failure_count: 0, circuit_breaker_state: :closed @@ -308,6 +332,8 @@ defmodule NameBadge.Weather do latitude: latitude, longitude: longitude, current_weather: true, + daily: "weather_code,temperature_2m_max,temperature_2m_min,wind_speed_10m_max", + forecast_days: 7, timezone: "auto" ] @@ -326,7 +352,9 @@ defmodule NameBadge.Weather do wind_speed_unit: units["windspeed"] || "km/h" } - {:ok, weather} + forecast = parse_daily_forecast(data) + + {:ok, weather, forecast} {:ok, %{status: status_code}} -> Logger.error("OpenMeteo API returned #{status_code}") @@ -338,6 +366,36 @@ defmodule NameBadge.Weather do end end + defp parse_daily_forecast(%{"daily" => daily, "daily_units" => daily_units}) do + dates = daily["time"] || [] + weather_codes = daily["weather_code"] || [] + max_temps = daily["temperature_2m_max"] || [] + min_temps = daily["temperature_2m_min"] || [] + max_winds = daily["wind_speed_10m_max"] || [] + + temp_unit = daily_units["temperature_2m_max"] || "°C" + wind_unit = daily_units["wind_speed_10m_max"] || "km/h" + + dates + |> Enum.with_index() + |> Enum.map(fn {date, i} -> + %{ + date: date, + weather_code: Enum.at(weather_codes, i), + max_temp: Enum.at(max_temps, i), + min_temp: Enum.at(min_temps, i), + max_wind: Enum.at(max_winds, i), + temperature_unit: temp_unit, + wind_speed_unit: wind_unit + } + end) + end + + defp parse_daily_forecast(_data) do + Logger.warning("No daily forecast data in API response") + [] + end + defp record_failure(state, _reason) do new_failure_count = state.failure_count + 1