291 lines
9.4 KiB
Elixir
291 lines
9.4 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} when is_binary(new_time) ->
|
|
# Parse string timestamp and compare
|
|
case DateTime.from_iso8601(new_time <> "Z") do
|
|
{:ok, new_dt, _} -> DateTime.after?(new_dt, current_time)
|
|
_ -> false
|
|
end
|
|
|
|
{new_time, current_time} when is_binary(current_time) ->
|
|
# Parse string timestamp and compare
|
|
case DateTime.from_iso8601(current_time <> "Z") do
|
|
{:ok, current_dt, _} -> DateTime.after?(new_time, current_dt)
|
|
_ -> true
|
|
end
|
|
|
|
{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
|
|
"N/A" -> false
|
|
"" -> false
|
|
nil -> 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
|
|
"N/A" ->
|
|
nil
|
|
|
|
nil ->
|
|
nil
|
|
|
|
value when is_binary(value) ->
|
|
case @weather_formatters[key] do
|
|
{formatter, separator} ->
|
|
{converted_value, unit} = formatter.(value, locale)
|
|
"#{converted_value}#{separator}#{unit}"
|
|
|
|
nil ->
|
|
"#{value}"
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
end
|
|
end
|
|
end
|