aprs.me/lib/aprsme_web/live/weather_live/callsign_view.ex
Graham McIntire 9b147101e1
fix: Handle both atom and string keys in weather view packet comparison
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>
2025-08-03 15:46:12 -05:00

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