The weather view was crashing with KeyError when receiving packets with string keys instead of atom keys. This was happening when packets were broadcast from different parts of the system. Changes: - Updated should_update_weather? to handle both atom and string keys - Added proper nil checking for received_at timestamps - Handle string timestamp comparisons as well as DateTime comparisons This prevents crashes when the weather view receives packets with inconsistent key types from PubSub broadcasts. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
271 lines
8.7 KiB
Elixir
271 lines
8.7 KiB
Elixir
defmodule AprsmeWeb.WeatherLive.CallsignView do
|
|
@moduledoc false
|
|
use AprsmeWeb, :live_view
|
|
use Gettext, backend: AprsmeWeb.Gettext
|
|
|
|
import Phoenix.LiveView, only: [push_event: 3, connected?: 1]
|
|
|
|
alias Aprsme.Callsign
|
|
alias AprsmeWeb.MapLive.PacketUtils
|
|
alias AprsmeWeb.TimeUtils
|
|
alias AprsmeWeb.WeatherUnits
|
|
|
|
@impl true
|
|
def mount(%{"callsign" => callsign}, _session, socket) do
|
|
normalized_callsign = Callsign.normalize(callsign)
|
|
|
|
# Subscribe to weather-specific topic for targeted updates
|
|
if connected?(socket) do
|
|
weather_topic = "weather:#{normalized_callsign}"
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, weather_topic)
|
|
end
|
|
|
|
weather_packet = get_latest_weather_packet(normalized_callsign)
|
|
{start_time, end_time} = default_time_range()
|
|
weather_history = get_weather_history(normalized_callsign, start_time, end_time)
|
|
|
|
# Get locale from socket assigns (set by LocaleHook)
|
|
locale = Map.get(socket.assigns, :locale, "en")
|
|
unit_labels = WeatherUnits.unit_labels(locale)
|
|
|
|
weather_history_json = convert_weather_history_to_json(weather_history, locale)
|
|
|
|
# Add chart labels for translation with locale-aware units
|
|
chart_labels = %{
|
|
temp_title: gettext("Temperature & Dew Point (%{unit})", unit: unit_labels.temperature),
|
|
temp_label: gettext("Temperature (%{unit})", unit: unit_labels.temperature),
|
|
dew_label: gettext("Dew Point (%{unit})", unit: unit_labels.temperature),
|
|
humidity_title: gettext("Humidity (%)"),
|
|
humidity_label: gettext("Humidity (%)"),
|
|
pressure_title: gettext("Pressure (mb)"),
|
|
pressure_label: gettext("Pressure (mb)"),
|
|
wind_title: gettext("Wind (%{unit})", unit: unit_labels.wind_speed),
|
|
wind_label: gettext("Wind Speed (%{unit})", unit: unit_labels.wind_speed),
|
|
gust_label: gettext("Wind Gust (%{unit})", unit: unit_labels.wind_speed),
|
|
time: gettext("Time"),
|
|
degf: unit_labels.temperature,
|
|
percent: gettext("%"),
|
|
mb: gettext("mb")
|
|
}
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:callsign, normalized_callsign)
|
|
|> assign(:weather_packet, weather_packet)
|
|
|> assign(:page_title, "Weather for #{normalized_callsign}")
|
|
|> assign(:weather_history, weather_history)
|
|
|> assign(:weather_history_json, weather_history_json)
|
|
|> assign(:chart_labels, chart_labels)
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:weather_packet, packet}, socket) do
|
|
# Check if this packet is actually newer than what we have
|
|
current_packet = socket.assigns.weather_packet
|
|
|
|
if should_update_weather?(current_packet, packet) do
|
|
update_weather_data(socket)
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
# Keep the old handler for backward compatibility
|
|
def handle_info({:postgres_packet, packet}, socket) do
|
|
# Only process if it's a packet with weather data for this callsign
|
|
has_weather_data =
|
|
not is_nil(packet.temperature) or not is_nil(packet.humidity) or
|
|
not is_nil(packet.pressure) or not is_nil(packet.wind_speed) or
|
|
not is_nil(packet.wind_direction) or not is_nil(packet.rain_1h)
|
|
|
|
if packet.sender == socket.assigns.callsign && has_weather_data do
|
|
handle_info({:weather_packet, packet}, socket)
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_info(_message, socket), do: {:noreply, socket}
|
|
|
|
defp should_update_weather?(nil, _new_packet), do: true
|
|
|
|
defp should_update_weather?(current_packet, new_packet) do
|
|
# Only update if the new packet is actually newer
|
|
# Handle both atom and string keys
|
|
new_received_at = Map.get(new_packet, :received_at) || Map.get(new_packet, "received_at")
|
|
current_received_at = Map.get(current_packet, :received_at) || Map.get(current_packet, "received_at")
|
|
|
|
case {new_received_at, current_received_at} do
|
|
{nil, _} ->
|
|
false
|
|
|
|
{_, nil} ->
|
|
true
|
|
|
|
{new_time, current_time} when is_binary(new_time) and is_binary(current_time) ->
|
|
new_time > current_time
|
|
|
|
{new_time, current_time} ->
|
|
DateTime.after?(new_time, current_time)
|
|
end
|
|
end
|
|
|
|
defp update_weather_data(socket) do
|
|
# Fetch updated data only once
|
|
weather_packet = get_latest_weather_packet(socket.assigns.callsign)
|
|
{start_time, end_time} = default_time_range()
|
|
weather_history = get_weather_history(socket.assigns.callsign, start_time, end_time)
|
|
|
|
# Get locale from socket assigns
|
|
locale = Map.get(socket.assigns, :locale, "en")
|
|
|
|
weather_history_json = convert_weather_history_to_json(weather_history, locale)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:weather_packet, weather_packet)
|
|
|> assign(:weather_history, weather_history)
|
|
|> assign(:weather_history_json, weather_history_json)
|
|
|
|
# Push event to update all charts with new data
|
|
socket = push_event(socket, "update_weather_charts", %{weather_history: weather_history_json})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp get_latest_weather_packet(callsign) do
|
|
# Use optimized cached query that checks recent data first
|
|
Aprsme.Packets.get_latest_weather_packet(callsign)
|
|
end
|
|
|
|
defp get_weather_history(callsign, start_time, end_time) do
|
|
# Use cached queries to avoid repeated database hits
|
|
Aprsme.Packets.get_weather_packets(callsign, start_time, end_time, %{limit: 500})
|
|
end
|
|
|
|
defp default_time_range do
|
|
TimeUtils.time_range(:last_two_days)
|
|
end
|
|
|
|
defp calc_dew_point(temp, humidity) when is_number(temp) and is_number(humidity) do
|
|
temp - (100 - humidity) / 5
|
|
end
|
|
|
|
defp calc_dew_point(_, _), do: nil
|
|
|
|
defp convert_weather_history_to_json(weather_history, locale) do
|
|
weather_history
|
|
|> Enum.map(fn pkt ->
|
|
dew_point =
|
|
if is_number(pkt.temperature) and is_number(pkt.humidity) do
|
|
calc_dew_point(pkt.temperature, pkt.humidity)
|
|
end
|
|
|
|
# Convert units based on locale
|
|
{temp_value, _} = WeatherUnits.format_temperature(pkt.temperature, locale)
|
|
{dew_value, _} = if dew_point, do: WeatherUnits.format_temperature(dew_point, locale), else: {nil, nil}
|
|
{wind_speed_value, _} = WeatherUnits.format_wind_speed(pkt.wind_speed, locale)
|
|
{rain_1h_value, _} = WeatherUnits.format_rain(pkt.rain_1h, locale)
|
|
{rain_24h_value, _} = WeatherUnits.format_rain(pkt.rain_24h, locale)
|
|
{rain_since_midnight_value, _} = WeatherUnits.format_rain(pkt.rain_since_midnight, locale)
|
|
|
|
%{
|
|
timestamp: pkt.received_at,
|
|
temperature: temp_value,
|
|
dew_point: dew_value,
|
|
humidity: pkt.humidity,
|
|
pressure: pkt.pressure,
|
|
wind_direction: pkt.wind_direction,
|
|
wind_speed: wind_speed_value,
|
|
rain_1h: rain_1h_value,
|
|
rain_24h: rain_24h_value,
|
|
rain_since_midnight: rain_since_midnight_value,
|
|
luminosity: pkt.luminosity
|
|
}
|
|
end)
|
|
|> Jason.encode!()
|
|
end
|
|
|
|
@doc """
|
|
Gets weather field value, returning "0" instead of "N/A" for missing numeric data.
|
|
"""
|
|
def get_weather_field_zero(packet, key) do
|
|
value = PacketUtils.get_weather_field(packet, key)
|
|
|
|
# Return "0" for missing numeric fields, keep other values as-is
|
|
case value do
|
|
"N/A" -> "0"
|
|
_ -> value
|
|
end
|
|
end
|
|
|
|
# Weather formatters mapping
|
|
@weather_formatters %{
|
|
temperature: {&WeatherUnits.format_temperature/2, ""},
|
|
wind_speed: {&WeatherUnits.format_wind_speed/2, " "},
|
|
wind_gust: {&WeatherUnits.format_wind_speed/2, " "},
|
|
rain_1h: {&WeatherUnits.format_rain/2, " "},
|
|
rain_24h: {&WeatherUnits.format_rain/2, " "},
|
|
rain_since_midnight: {&WeatherUnits.format_rain/2, " "}
|
|
}
|
|
|
|
@doc """
|
|
Checks if a weather field has a valid value (not nil, "N/A", or empty).
|
|
"""
|
|
def has_weather_field?(packet, field) do
|
|
value = PacketUtils.get_weather_field(packet, field)
|
|
|
|
case value do
|
|
nil -> false
|
|
"N/A" -> false
|
|
"" -> false
|
|
_ -> true
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Formats weather values with appropriate units based on locale.
|
|
"""
|
|
def format_weather_value(packet, key, locale) do
|
|
value = PacketUtils.get_weather_field(packet, key)
|
|
|
|
case value do
|
|
nil ->
|
|
nil
|
|
|
|
"N/A" ->
|
|
nil
|
|
|
|
value when is_number(value) ->
|
|
case @weather_formatters[key] do
|
|
{formatter, separator} ->
|
|
{converted_value, unit} = formatter.(value, locale)
|
|
"#{converted_value}#{separator}#{unit}"
|
|
|
|
nil ->
|
|
"#{value}"
|
|
end
|
|
|
|
value when is_binary(value) ->
|
|
case @weather_formatters[key] do
|
|
{formatter, separator} ->
|
|
case Float.parse(value) do
|
|
{num_value, _} ->
|
|
{converted_value, unit} = formatter.(num_value, locale)
|
|
"#{converted_value}#{separator}#{unit}"
|
|
|
|
:error ->
|
|
nil
|
|
end
|
|
|
|
nil ->
|
|
"#{value}"
|
|
end
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
end
|