diff --git a/assets/js/app.js b/assets/js/app.js index 4ed6428..7960352 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,11 +25,11 @@ import topbar from "../vendor/topbar"; let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); // Import minimal APRS map hook -import MinimalAPRSMap from "./minimal_map"; +import MapAPRSMap from "./map"; // APRS Map Hook let Hooks = {}; -Hooks.APRSMap = MinimalAPRSMap; +Hooks.APRSMap = MapAPRSMap; let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, diff --git a/assets/js/minimal_map.ts b/assets/js/map.ts similarity index 87% rename from assets/js/minimal_map.ts rename to assets/js/map.ts index 161aa55..448377b 100644 --- a/assets/js/minimal_map.ts +++ b/assets/js/map.ts @@ -1,9 +1,15 @@ // Declare Leaflet as a global variable declare const L: any; -// Minimal APRS Map Hook - handles only basic map interaction +// APRS Map Hook - handles only basic map interaction // All data logic handled by LiveView +declare global { + interface Window { + __aprs_map_mounted?: boolean; + } +} + type LiveViewHookContext = { el: HTMLElement & { _leaflet_id?: any }; pushEvent: (event: string, payload: any) => void; @@ -18,6 +24,7 @@ type LiveViewHookContext = { initializationAttempts?: number; maxInitializationAttempts?: number; lastZoom?: number; + currentPopupMarkerId?: string | null; [key: string]: any; }; @@ -67,8 +74,13 @@ interface MapEventData { markers?: MarkerData[]; } -let MinimalAPRSMap = { +let MapAPRSMap = { mounted() { + if (window.__aprs_map_mounted) { + console.warn("APRS Map already mounted, skipping duplicate mount."); + return; + } + window.__aprs_map_mounted = true; const self = this as unknown as LiveViewHookContext; // Initialize error tracking self.errors = []; @@ -442,6 +454,34 @@ let MinimalAPRSMap = { historical: false, popup: self.buildPopupContent(data), }); + // Send marker_clicked for the latest packet only + self.pushEvent("marker_clicked", { + id: data.id, + callsign: data.callsign, + lat: data.lat, + lng: data.lng, + }); + }); + + // Handle highlighting the latest packet (open its popup) + self.currentPopupMarkerId = null; + self.handleEvent("highlight_packet", (data: { id: string }) => { + if (!data.id) return; + // Close previous popup if open + if (self.currentPopupMarkerId && self.markers!.has(self.currentPopupMarkerId)) { + const prevMarker = self.markers!.get(self.currentPopupMarkerId); + if (prevMarker && prevMarker.closePopup) prevMarker.closePopup(); + } + // Re-add marker with openPopup flag (will open after add) + const markerData = self.markerStates!.get(data.id); + if (markerData) { + self.addMarker({ ...markerData, id: data.id, openPopup: true }); + } else { + // fallback: try to open popup directly if marker exists + const marker = self.markers!.get(data.id); + if (marker && marker.openPopup) marker.openPopup(); + } + self.currentPopupMarkerId = data.id; }); // Handle historical packets during replay @@ -518,7 +558,7 @@ let MinimalAPRSMap = { }); }, - addMarker(data: MarkerData) { + addMarker(data: MarkerData & { openPopup?: boolean }) { const self = this as unknown as LiveViewHookContext; if (!data.id || !data.lat || !data.lng) { console.warn("Invalid marker data:", data); @@ -550,6 +590,10 @@ let MinimalAPRSMap = { if (!positionChanged && !dataChanged) { // No changes needed, skip update + // But if openPopup is requested, open it + if (data.openPopup && existingMarker.openPopup) { + existingMarker.openPopup(); + } return; } } @@ -557,11 +601,14 @@ let MinimalAPRSMap = { // Remove existing marker if it exists self.removeMarker(data.id); - // Create marker icon - const icon = self.createMarkerIcon(data); - - // Create marker - const marker = L.marker([lat, lng], { icon: icon }); + // Create marker (small blue circle) + const marker = L.circleMarker([lat, lng], { + radius: 6, + color: "#007bff", + fillColor: "#007bff", + fillOpacity: 1, + weight: 1 + }); // Add popup if content provided if (data.popup) { @@ -570,6 +617,7 @@ let MinimalAPRSMap = { // Handle marker click marker.on("click", () => { + if (marker.openPopup) marker.openPopup(); self.pushEvent("marker_clicked", { id: data.id, callsign: data.callsign, @@ -596,6 +644,11 @@ let MinimalAPRSMap = { popup: data.popup, historical: data.historical, }); + + // Open popup if requested + if (data.openPopup && marker.openPopup) { + marker.openPopup(); + } }, removeMarker(id: string) { @@ -629,10 +682,11 @@ let MinimalAPRSMap = { } // Update icon if data changed - if (data.symbol_table_id || data.symbol_code || data.color) { - const newIcon = self.createMarkerIcon(data); - existingMarker.setIcon(newIcon); - } + // (No longer updating icon, always use default marker) + // if (data.symbol_table_id || data.symbol_code || data.color) { + // const newIcon = self.createMarkerIcon(data); + // existingMarker.setIcon(newIcon); + // } } else { // Marker doesn't exist, create it self.addMarker(data); @@ -684,57 +738,40 @@ let MinimalAPRSMap = { const symbolTableId = data.symbol_table_id || "/"; const symbolCode = data.symbol_code || ">"; - // Get the correct sprite sheet based on symbol table - // Use high-DPI versions (@2x) for better quality - const spriteFile = symbolTableId === "/" - ? "/aprs-symbols/aprs-symbols-24-0@2x.png" - : "/aprs-symbols/aprs-symbols-24-1@2x.png"; + // Map symbol table identifier to correct table index per hessu/aprs-symbols + const tableMap: Record = { + "/": "0", + "\\": "1", + "]": "2" + }; + const tableId = tableMap[symbolTableId] || "0"; + const spriteFile = `/aprs-symbols/aprs-symbols-24-${tableId}@2x.png`; - // Calculate sprite position - // The sprite sheet is organized as a 16x8 grid (128 symbols total) - // ASCII codes 32-127 map to positions 0-95 + // Calculate sprite position per hessu/aprs-symbols const charCode = symbolCode.charCodeAt(0); - - // Convert ASCII to sprite sheet position - // The sprite sheet is organized in a specific way: - // - First row (0): ASCII 32-47 - // - Second row (1): ASCII 48-63 - // - Third row (2): ASCII 64-79 - // - Fourth row (3): ASCII 80-95 - // - Fifth row (4): ASCII 96-111 - // - Sixth row (5): ASCII 112-127 - // - Rows 6-7: Reserved for future use - const position = charCode - 32; - const row = Math.floor(position / 16); - const column = position % 16; + const index = charCode - 32; + // Clamp to valid range (0-127) + const safeIndex = Math.max(0, Math.min(index, 127)); + const row = Math.floor(safeIndex / 16); + const column = safeIndex % 16; // Each symbol is 48x48 pixels in @2x version (24x24 * 2) const x = -column * 48; const y = -row * 48; - // Debug info - console.log('Symbol debug:', { - symbolTableId, - symbolCode, - charCode, - position, - row, - column, - x, - y, - spriteFile - }); - - // Create icon element + // Create icon element (48x48, scaled down to 24x24) const icon = document.createElement('div'); - icon.style.width = '24px'; - icon.style.height = '24px'; + icon.style.width = '48px'; + icon.style.height = '48px'; icon.style.backgroundImage = `url(${spriteFile})`; icon.style.backgroundPosition = `${x}px ${y}px`; icon.style.backgroundSize = '768px 384px'; // 16x8 grid of 48x48 symbols (@2x) icon.style.backgroundRepeat = 'no-repeat'; - icon.style.imageRendering = 'pixelated'; // Ensure crisp pixel rendering + icon.style.imageRendering = 'pixelated'; icon.style.opacity = data.historical ? '0.7' : '1.0'; + icon.style.transform = 'scale(0.5)'; + icon.style.transformOrigin = 'top left'; + icon.style.overflow = 'hidden'; return L.divIcon({ html: icon, @@ -793,4 +830,4 @@ let MinimalAPRSMap = { }, }; -export default MinimalAPRSMap; +export default MapAPRSMap; diff --git a/docs/APRS_SYMBOLS.md b/docs/APRS_SYMBOLS.md deleted file mode 100644 index 34ca7a3..0000000 --- a/docs/APRS_SYMBOLS.md +++ /dev/null @@ -1,173 +0,0 @@ -# APRS Symbol Implementation - -This document describes the implementation of APRS icons/symbols in the LiveView-based map system, replacing the generic colored dots with proper APRS symbols. - -## Overview - -The APRS.me application now displays proper APRS symbols instead of generic dots for packet markers on the map. This implementation uses the high-resolution APRS symbol set from [hessu/aprs-symbols](https://github.com/hessu/aprs-symbols). - -## Map Versions - -The application now has three map implementations: - -- `/` - **Default LiveView Map** - Minimal LiveView-based map with APRS symbols (recommended) -- `/enhanced` - **Enhanced LiveView Map** - Feature-rich LiveView map with additional controls -- `/old` - **Legacy Map** - Original JavaScript-heavy implementation (deprecated) - -## APRS Symbol System - -### Symbol Tables - -APRS uses two main symbol tables: - -- **Primary Table** (`/`): `aprs-symbols-24-0.png` - Standard symbols -- **Secondary Table** (`\`): `aprs-symbols-24-1.png` - Alternate symbols -- **Overlay Characters**: `aprs-symbols-24-2.png` - Overlay digits and letters - -### Symbol Structure - -Each symbol is identified by: -- **Symbol Table ID**: `/` (primary) or `\` (secondary) -- **Symbol Code**: ASCII character (33-126) representing the symbol position - -### Symbol Positioning - -Symbols are arranged in a 16×6 grid (96 symbols total) in each sprite sheet: -- Position = ASCII code - 32 -- Column = Position % 16 -- Row = Position ÷ 16 -- Each symbol is 24×24 pixels - -## Implementation Details - -### Backend Components - -#### AprsWeb.Helpers.AprsSymbols - -Helper module for APRS symbol operations: - -```elixir -# Get sprite sheet filename -AprsSymbols.get_sprite_filename("/") # → "aprs-symbols-24-0.png" - -# Calculate symbol position -AprsSymbols.get_symbol_position(">") # → {-336, -24} - -# Generate CSS for symbol display -AprsSymbols.symbol_css_style("/", ">") - -# Get human-readable description -AprsSymbols.symbol_description("/", ">") # → "Car" -``` - -#### Packet Processing - -Modified `build_packet_data/1` in `MapLive.Index`: -- Extracts symbol information from packet data -- Validates symbols and provides defaults -- Includes coordinates and symbol metadata for frontend - -#### Struct Conversion - -Enhanced `struct_to_map/1` in `Aprs.Is`: -- Recursively converts nested structs to maps -- Preserves type information for proper data extraction -- Handles MicE packets specially - -### Frontend Components - -#### JavaScript Updates - -Updated `minimal_map.js`: -- Added handlers for `new_packet` and `historical_packet` events -- Modified `createMarkerIcon()` to use sprite-based symbols -- Added popup content generation with symbol descriptions - -#### Symbol Rendering - -Symbols are displayed using CSS sprites: -- Background image points to appropriate sprite sheet -- Background position calculated from symbol code -- High-DPI support with @2x variants -- Opacity adjustment for historical markers - -### Asset Files - -Downloaded APRS symbol files in `priv/static/aprs-symbols/`: -- `aprs-symbols-24-0.png` - Primary table (24×24) -- `aprs-symbols-24-1.png` - Secondary table (24×24) -- `aprs-symbols-24-0@2x.png` - Primary table (48×48, retina) -- `aprs-symbols-24-1@2x.png` - Secondary table (48×48, retina) -- `aprs-symbols-24-2.png` - Overlay characters (24×24) -- `aprs-symbols-24-2@2x.png` - Overlay characters (48×48, retina) - -## Common APRS Symbols - -| Symbol Code | Table | Description | -|-------------|-------|-------------| -| `>` | `/` | Car | -| `k` | `/` | Truck | -| `j` | `/` | Jeep | -| `f` | `/` | Fire truck | -| `a` | `/` | Ambulance | -| `b` | `/` | Bike | -| `^` | `/` | Aircraft | -| `s` | `/` | Ship | -| `-` | `/` | House | -| `r` | `/` | Repeater | -| `_` | `/` | Weather | - -## Configuration - -### Default Symbols - -- **Unknown packets**: Car symbol (`/`, `>`) -- **MicE packets**: Car symbol (`/`, `>`) -- **Invalid symbols**: Fall back to car symbol - -### Styling - -CSS classes for customization: -- `.aprs-marker` - Base marker styling -- `.historical-marker` - Historical packet opacity -- `.aprs-popup` - Popup content styling -- `.aprs-callsign` - Callsign display -- `.aprs-symbol-info` - Symbol description -- `.aprs-comment` - Packet comment -- `.aprs-coords` - Coordinate display - -## High-DPI Support - -Automatic retina display support using CSS media queries: -```css -@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { - .aprs-marker div[style*="aprs-symbols-24-0.png"] { - background-image: url('/aprs-symbols/aprs-symbols-24-0@2x.png') !important; - background-size: 384px 144px !important; - } -} -``` - -## Navigation - -Added floating navigation headers to all map versions: -- Links between different map implementations -- Access to status and packet pages -- Consistent UI across all versions - -## Symbol Credits - -APRS symbols from [hessu/aprs-symbols](https://github.com/hessu/aprs-symbols): -- Created by Heikki Hannikainen OH7LZB -- High-resolution vector-based symbol set -- Released to APRS community for free use -- Compatible with Updated APRS Symbol Set (Rev H) - -## Future Enhancements - -Potential improvements: -- Overlay character support for numbered/lettered symbols -- Symbol rotation based on course/heading -- Custom symbol upload functionality -- Symbol animation for moving objects -- Symbol filtering and categorization \ No newline at end of file diff --git a/lib/aprs/is/is.ex b/lib/aprs/is/is.ex index 56c02af..e6e33be 100644 --- a/lib/aprs/is/is.ex +++ b/lib/aprs/is/is.ex @@ -2,8 +2,6 @@ defmodule Aprs.Is do @moduledoc false use GenServer - alias Parser.Types.MicE - require Logger @aprs_timeout 60 * 1000 @@ -387,39 +385,34 @@ defmodule Aprs.Is do require Logger try do - # Store the packet if it has position data - if has_position_data?(parsed_message) do - # Always set received_at timestamp to ensure consistency - current_time = DateTime.truncate(DateTime.utc_now(), :microsecond) - packet_data = Map.put(parsed_message, :received_at, current_time) + # Always set received_at timestamp to ensure consistency + current_time = DateTime.truncate(DateTime.utc_now(), :microsecond) + packet_data = Map.put(parsed_message, :received_at, current_time) - # Convert to map before storing to avoid struct conversion issues - attrs = struct_to_map(packet_data) + # Convert to map before storing to avoid struct conversion issues + attrs = struct_to_map(packet_data) - # Extract additional data from the parsed packet including raw packet - attrs = Aprs.Packet.extract_additional_data(attrs, message) + # Extract additional data from the parsed packet including raw packet + attrs = Aprs.Packet.extract_additional_data(attrs, message) - # Normalize data_type to string if it's an atom - attrs = normalize_data_type(attrs) + # Normalize data_type to string if it's an atom + attrs = normalize_data_type(attrs) - # Store in database through the Packets context - case Aprs.Packets.store_packet(attrs) do - {:ok, _packet} -> - # Packet stored successfully - :ok + # Store in database through the Packets context + case Aprs.Packets.store_packet(attrs) do + {:ok, _packet} -> + # Packet stored successfully + :ok - {:error, :storage_exception} -> - Logger.error("Storage exception while storing packet from #{inspect(parsed_message.sender)}") + {:error, :storage_exception} -> + Logger.error("Storage exception while storing packet from #{inspect(parsed_message.sender)}") - Logger.debug("Packet attributes that failed: #{inspect(attrs)}") + Logger.debug("Packet attributes that failed: #{inspect(attrs)}") - {:error, :validation_error} -> - Logger.error("Validation error while storing packet from #{inspect(parsed_message.sender)}") + {:error, :validation_error} -> + Logger.error("Validation error while storing packet from #{inspect(parsed_message.sender)}") - Logger.debug("Packet attributes that failed: #{inspect(attrs)}") - end - else - Logger.debug("Skipping packet without position data: #{inspect(parsed_message.sender)}") + Logger.debug("Packet attributes that failed: #{inspect(attrs)}") end rescue error -> @@ -447,93 +440,6 @@ defmodule Aprs.Is do end end - # Helper to check if a packet has position data worth storing - @spec has_position_data?(map()) :: boolean() - defp has_position_data?(%{data_extended: nil} = packet), do: log_and_return_nil_data_extended(packet) - - defp has_position_data?(%{data_extended: %{latitude: lat, longitude: lon}} = _packet) - when not is_nil(lat) and not is_nil(lon), - do: check_lat_lon_coords(lat, lon) - - defp has_position_data?(%{data_extended: %MicE{} = mic_e}), do: check_mic_e_coords(mic_e) - defp has_position_data?(packet), do: log_and_return_unrecognized(packet) - - defp log_and_return_nil_data_extended(packet) do - require Logger - - Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}") - false - end - - defp check_lat_lon_coords(lat, lon) do - valid = are_valid_coords?(lat, lon) - - if !valid do - require Logger - - Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}") - end - - valid - end - - defp check_mic_e_coords(mic_e) do - is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and - is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes) - end - - defp log_and_return_unrecognized(packet) do - require Logger - - Logger.debug("Unrecognized packet format: #{inspect(Map.keys(packet))}") - false - end - - # Helper to validate coordinate values - @spec are_valid_coords?(any(), any()) :: boolean() - defp are_valid_coords?(lat, lon) do - require Logger - - # Convert to float if possible - lat_float = to_float(lat) - lon_float = to_float(lon) - - # Log coordinate conversion - if !is_number(lat_float) do - Logger.debug("Could not convert latitude to float: #{inspect(lat)}") - end - - if !is_number(lon_float) do - Logger.debug("Could not convert longitude to float: #{inspect(lon)}") - end - - # Check if conversion succeeded and values are in valid ranges - result = - is_number(lat_float) and is_number(lon_float) and - lat_float >= -90 and lat_float <= 90 and lon_float >= -180 and lon_float <= 180 - - if !result do - Logger.debug("Invalid coordinates: lat=#{inspect(lat_float)}, lon=#{inspect(lon_float)}") - end - - result - end - - # Helper to convert various types to float - @spec to_float(any()) :: float() | nil - defp to_float(value) when is_float(value), do: value - defp to_float(value) when is_integer(value), do: value * 1.0 - defp to_float(%Decimal{} = value), do: Decimal.to_float(value) - - defp to_float(value) when is_binary(value) do - case Float.parse(value) do - {float, _} -> float - :error -> nil - end - end - - defp to_float(_), do: nil - # Normalize data_type to ensure proper storage @spec normalize_data_type(map()) :: map() defp normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do @@ -542,10 +448,6 @@ defmodule Aprs.Is do defp normalize_data_type(attrs), do: attrs - # total_spec = [{{:"$1", :_, :"$2"}, [{:andalso, callsign_guard, timestamp_guard}], [true]}] - # :ets.select_count(:aprs_messages, total_spec) - # end - @spec update_packet_stats(map(), integer()) :: map() defp update_packet_stats(stats, current_time) do new_total = stats.total_packets + 1 diff --git a/lib/aprs_web/helpers/aprs_symbols.ex b/lib/aprs_web/helpers/aprs_symbols.ex deleted file mode 100644 index 28906bd..0000000 --- a/lib/aprs_web/helpers/aprs_symbols.ex +++ /dev/null @@ -1,352 +0,0 @@ -defmodule AprsWeb.Helpers.AprsSymbols do - @moduledoc """ - Helper functions for working with APRS symbols. - - APRS symbols are organized in sprite sheets: - - Primary table (symbol_table_id = "/"): aprs-symbols-24-0.png - - Secondary table (symbol_table_id = "\\"): aprs-symbols-24-1.png - - Overlay characters: aprs-symbols-24-2.png - - Each sprite sheet is a 16x6 grid (96 symbols total). - Symbol positioning is based on ASCII code of the symbol_code. - """ - - @symbol_descriptions %{ - {"/", "!"} => "Police/Sheriff", - {"/", "\""} => "Reserved", - {"/", "#"} => "Digipeater", - {"/", "$"} => "Phone", - {"/", "%"} => "DX Cluster", - {"/", "&"} => "HF Gateway", - {"/", "'"} => "Small Aircraft", - {"/", "("} => "Mobile Satellite Station", - {"/", ")"} => "Wheelchair", - {"/", "*"} => "Snowmobile", - {"/", "+"} => "Red Cross", - {"/", ","} => "Boy Scout", - {"/", "-"} => "House", - {"/", "."} => "X", - {"/", "/"} => "Position", - {"/", "0"} => "Circle", - {"/", "1"} => "Circle", - {"/", "2"} => "Circle", - {"/", "3"} => "Circle", - {"/", "4"} => "Circle", - {"/", "5"} => "Circle", - {"/", "6"} => "Circle", - {"/", "7"} => "Circle", - {"/", "8"} => "Circle", - {"/", "9"} => "Circle", - {"/", ":"} => "Fire Department", - {"/", ";"} => "Campground", - {"/", "<"} => "Motorcycle", - {"/", "="} => "Rail Engine", - {"/", ">"} => "Car", - {"/", "?"} => "File Server", - {"/", "@"} => "HC Future", - {"/", "A"} => "Aid Station", - {"/", "B"} => "BBS", - {"/", "C"} => "Canoe", - {"/", "D"} => "Reserved", - {"/", "E"} => "Eyeball", - {"/", "F"} => "Tractor", - {"/", "G"} => "Grid Square", - {"/", "H"} => "Hotel", - {"/", "I"} => "TCP/IP", - {"/", "J"} => "Phone", - {"/", "K"} => "School", - {"/", "L"} => "PC User", - {"/", "M"} => "MacAPRS", - {"/", "N"} => "NTS Station", - {"/", "O"} => "Balloon", - {"/", "P"} => "Police", - {"/", "Q"} => "TBD", - {"/", "R"} => "Recreational Vehicle", - {"/", "S"} => "Shuttle", - {"/", "T"} => "SSTV", - {"/", "U"} => "Bus", - {"/", "V"} => "ATV", - {"/", "W"} => "National Weather Service", - {"/", "X"} => "Helo", - {"/", "Y"} => "Yacht", - {"/", "Z"} => "WinAPRS", - {"/", "["} => "Jogger", - {"/", "\\"} => "Triangle", - {"/", "]"} => "PBBS", - {"/", "^"} => "Aircraft", - {"/", "_"} => "Weather Station", - {"/", "`"} => "Dish Antenna", - {"/", "a"} => "Ambulance", - {"/", "b"} => "Bike", - {"/", "c"} => "Incident Command Post", - {"/", "d"} => "Fire Depts", - {"/", "e"} => "Horse", - {"/", "f"} => "Fire Truck", - {"/", "g"} => "Glider", - {"/", "h"} => "Hospital", - {"/", "i"} => "IOTA", - {"/", "j"} => "Jeep", - {"/", "k"} => "Truck", - {"/", "l"} => "Laptop", - {"/", "m"} => "Mic-E", - {"/", "n"} => "Node", - {"/", "o"} => "EOC", - {"/", "p"} => "Dog", - {"/", "q"} => "Grid", - {"/", "r"} => "Repeater", - {"/", "s"} => "Ship", - {"/", "t"} => "Truck Stop", - {"/", "u"} => "Truck", - {"/", "v"} => "Van", - {"/", "w"} => "Water Station", - {"/", "x"} => "X-APRS", - {"/", "y"} => "Yagi", - {"/", "z"} => "Shelter", - {"\\", "!"} => "Emergency", - {"\\", "\""} => "Reserved", - {"\\", "#"} => "Digipeater", - {"\\", "$"} => "Bank", - {"\\", "%"} => "Reserved", - {"\\", "&"} => "Reserved", - {"\\", "'"} => "Reserved", - {"\\", "("} => "Reserved", - {"\\", ")"} => "Reserved", - {"\\", "*"} => "Reserved", - {"\\", "+"} => "Reserved", - {"\\", ","} => "Reserved", - {"\\", "-"} => "Reserved", - {"\\", "."} => "Reserved", - {"\\", "/"} => "Triangle", - {"\\", "0"} => "Reserved", - {"\\", "1"} => "Reserved", - {"\\", "2"} => "Reserved", - {"\\", "3"} => "Reserved", - {"\\", "4"} => "Reserved", - {"\\", "5"} => "Reserved", - {"\\", "6"} => "Reserved", - {"\\", "7"} => "Reserved", - {"\\", "8"} => "Reserved", - {"\\", "9"} => "Reserved", - {"\\", ":"} => "Reserved", - {"\\", ";"} => "Reserved", - {"\\", "<"} => "Reserved", - {"\\", "="} => "Reserved", - {"\\", ">"} => "Car (alternate)", - {"\\", "?"} => "Reserved", - {"\\", "@"} => "Reserved", - {"\\", "A"} => "Reserved", - {"\\", "B"} => "Reserved", - {"\\", "C"} => "Reserved", - {"\\", "D"} => "Reserved", - {"\\", "E"} => "Reserved", - {"\\", "F"} => "Reserved", - {"\\", "G"} => "Reserved", - {"\\", "H"} => "Reserved", - {"\\", "I"} => "Reserved", - {"\\", "J"} => "Reserved", - {"\\", "K"} => "Reserved", - {"\\", "L"} => "Reserved", - {"\\", "M"} => "Reserved", - {"\\", "N"} => "Reserved", - {"\\", "O"} => "Reserved", - {"\\", "P"} => "Reserved", - {"\\", "Q"} => "Reserved", - {"\\", "R"} => "Reserved", - {"\\", "S"} => "Reserved", - {"\\", "T"} => "Reserved", - {"\\", "U"} => "Reserved", - {"\\", "V"} => "Reserved", - {"\\", "W"} => "Reserved", - {"\\", "X"} => "Reserved", - {"\\", "Y"} => "Reserved", - {"\\", "Z"} => "Reserved", - {"\\", "["} => "Reserved", - {"\\", "\\"} => "Reserved", - {"\\", "]"} => "Reserved", - {"\\", "^"} => "Reserved", - {"\\", "_"} => "Reserved", - {"\\", "`"} => "Reserved", - {"\\", "a"} => "Reserved", - {"\\", "b"} => "Reserved", - {"\\", "c"} => "Reserved", - {"\\", "d"} => "Reserved", - {"\\", "e"} => "Reserved", - {"\\", "f"} => "Reserved", - {"\\", "g"} => "Reserved", - {"\\", "h"} => "Reserved", - {"\\", "i"} => "Reserved", - {"\\", "j"} => "Reserved", - {"\\", "k"} => "Reserved", - {"\\", "l"} => "Reserved", - {"\\", "m"} => "Reserved", - {"\\", "n"} => "Reserved", - {"\\", "o"} => "Reserved", - {"\\", "p"} => "Reserved", - {"\\", "q"} => "Reserved", - {"\\", "r"} => "Reserved", - {"\\", "s"} => "Reserved" - } - - @doc """ - Get the sprite sheet filename for a given symbol table ID. - - ## Examples - - iex> AprsWeb.Helpers.AprsSymbols.get_sprite_filename("/") - "aprs-symbols-24-0.png" - - iex> AprsWeb.Helpers.AprsSymbols.get_sprite_filename("\\") - "aprs-symbols-24-1.png" - """ - @spec get_sprite_filename(String.t()) :: String.t() - def get_sprite_filename(symbol_table_id) do - case symbol_table_id do - "/" -> "aprs-symbols-24-0.png" - "\\" -> "aprs-symbols-24-1.png" - # Default to primary table - _ -> "aprs-symbols-24-0.png" - end - end - - @doc """ - Get the high-resolution sprite sheet filename for retina displays. - """ - @spec get_sprite_filename_2x(String.t()) :: String.t() - def get_sprite_filename_2x(symbol_table_id) do - case symbol_table_id do - "/" -> "aprs-symbols-24-0@2x.png" - "\\" -> "aprs-symbols-24-1@2x.png" - _ -> "aprs-symbols-24-0@2x.png" - end - end - - @doc """ - Calculate the CSS background position for a symbol in the sprite sheet. - - The sprite sheet is organized as a 16x6 grid. - ASCII codes 32-127 map to positions 0-95. - - ## Examples - - iex> AprsWeb.Helpers.AprsSymbols.get_symbol_position(">") - {-1440, 0} # ASCII 62, position 30 -> column 14, row 1 - - iex> AprsWeb.Helpers.AprsSymbols.get_symbol_position("!") - {-216, 0} # ASCII 33, position 1 -> column 1, row 0 - """ - @spec get_symbol_position(String.t() | integer()) :: {integer(), integer()} - def get_symbol_position(symbol_code) when is_binary(symbol_code) do - # Get first character if string - char_code = symbol_code |> String.first() |> String.to_charlist() |> List.first() - get_symbol_position_by_ascii(char_code) - end - - def get_symbol_position(symbol_code) when is_integer(symbol_code) do - get_symbol_position_by_ascii(symbol_code) - end - - defp get_symbol_position_by_ascii(ascii_code) do - # ASCII 32 (space) is position 0, ASCII 127 (DEL) is position 95 - position = ascii_code - 32 - - # Clamp position to valid range 0-95 - position = max(0, min(95, position)) - - # Calculate row and column (16 symbols per row) - col = rem(position, 16) - row = div(position, 16) - - # Each symbol is 24x24 pixels - x = -col * 24 - y = -row * 24 - - {x, y} - end - - @doc """ - Generate CSS style for displaying an APRS symbol using sprite positioning. - - ## Examples - - iex> AprsWeb.Helpers.AprsSymbols.symbol_css_style("/", ">") - "background-image: url('/aprs-symbols/aprs-symbols-24-0.png'); background-position: -1440px 0px; width: 24px; height: 24px;" - """ - @spec symbol_css_style(String.t(), String.t() | integer()) :: String.t() - def symbol_css_style(symbol_table_id, symbol_code) do - sprite_file = get_sprite_filename(symbol_table_id) - {x, y} = get_symbol_position(symbol_code) - - "background-image: url('/aprs-symbols/#{sprite_file}'); " <> - "background-position: #{x}px #{y}px; " <> - "width: 24px; height: 24px; " <> - "background-repeat: no-repeat;" - end - - @doc """ - Generate HTML for an APRS symbol with proper sprite positioning. - """ - @spec symbol_html(String.t(), String.t() | integer(), keyword()) :: {:safe, String.t()} - def symbol_html(symbol_table_id, symbol_code, opts \\ []) do - css_class = Keyword.get(opts, :class, "aprs-symbol") - extra_style = Keyword.get(opts, :style, "") - - base_style = symbol_css_style(symbol_table_id, symbol_code) - full_style = if extra_style == "", do: base_style, else: base_style <> " " <> extra_style - - Phoenix.HTML.raw(~s(
)) - end - - @doc """ - Get a data URL for the symbol that can be used in JavaScript/Leaflet. - This creates a small canvas with just the symbol extracted from the sprite. - """ - @spec get_symbol_data_attributes(String.t(), String.t() | integer()) :: map() - def get_symbol_data_attributes(symbol_table_id, symbol_code) do - sprite_file = get_sprite_filename(symbol_table_id) - sprite_file_2x = get_sprite_filename_2x(symbol_table_id) - {x, y} = get_symbol_position(symbol_code) - - %{ - "data-sprite" => sprite_file, - "data-sprite-2x" => sprite_file_2x, - "data-pos-x" => x, - "data-pos-y" => y, - "data-symbol-table" => symbol_table_id, - "data-symbol-code" => symbol_code - } - end - - @doc """ - Get the default symbol for unknown or invalid symbols. - """ - @spec default_symbol() :: {String.t(), String.t()} - def default_symbol do - # Car icon as default - {"/", ">"} - end - - @doc """ - Validate if a symbol table ID and code combination is valid. - """ - @spec valid_symbol?(any(), any()) :: boolean() - def valid_symbol?(symbol_table_id, symbol_code) do - case {symbol_table_id, symbol_code} do - {table, code} when table in ["/", "\\"] and is_binary(code) and byte_size(code) > 0 -> - char_code = code |> String.first() |> String.to_charlist() |> List.first() - char_code >= 32 and char_code <= 127 - - _ -> - false - end - end - - @doc """ - Get a human-readable description for common APRS symbols. - This is a subset of common symbols - for a complete list, you'd want to - reference the official APRS symbol specification. - """ - @spec symbol_description(String.t(), String.t()) :: String.t() - def symbol_description(symbol_table_id, symbol_code) do - Map.get(@symbol_descriptions, {symbol_table_id, symbol_code}, "Unknown symbol") - end -end diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 9db22ea..09ea5b6 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -5,7 +5,6 @@ defmodule AprsWeb.MapLive.Index do use AprsWeb, :live_view alias AprsWeb.Endpoint - alias AprsWeb.Helpers.AprsSymbols alias Phoenix.LiveView.Socket @default_center %{lat: 39.8283, lng: -98.5795} @@ -21,6 +20,7 @@ defmodule AprsWeb.MapLive.Index do socket = assign_defaults(socket, one_hour_ago) socket = assign(socket, packet_buffer: [], buffer_timer: nil) + socket = assign(socket, all_packets: %{}) if connected?(socket) do Endpoint.subscribe("aprs_messages") @@ -310,6 +310,31 @@ defmodule AprsWeb.MapLive.Index do push_event(acc, "remove_marker", %{id: k}) end) + # Fetch the latest 100 packets within the new bounds and push to client + bounds_list = [map_bounds.west, map_bounds.south, map_bounds.east, map_bounds.north] + now = DateTime.utc_now() + one_hour_ago = DateTime.add(now, -3600, :second) + + packets = + Aprs.Packets.get_packets_for_replay(%{ + bounds: bounds_list, + start_time: one_hour_ago, + end_time: now, + limit: 100 + }) + + marker_data = + packets + |> Enum.map(&build_packet_data/1) + |> Enum.filter(& &1) + + socket = + if Enum.any?(marker_data) do + push_event(socket, "add_markers", %{markers: marker_data}) + else + socket + end + assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets) end @@ -382,6 +407,15 @@ defmodule AprsWeb.MapLive.Index do defp handle_info_postgres_packet(packet, socket) do {lat, lon, _data_extended} = get_coordinates(packet) + # Always store the packet in all_packets + callsign_key = + if Map.has_key?(packet, "id"), + do: to_string(packet["id"]), + else: System.unique_integer([:positive]) + + all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet) + socket = assign(socket, all_packets: all_packets) + if is_nil(lat) or is_nil(lon), do: {:noreply, socket}, else: handle_valid_postgres_packet(packet, lat, lon, socket) @@ -409,40 +443,58 @@ defmodule AprsWeb.MapLive.Index do defp handle_info_flush_packet_buffer(socket) do packets = Enum.reverse(socket.assigns.packet_buffer) visible_packets = socket.assigns.visible_packets + all_packets = socket.assigns.all_packets - {new_visible_packets, events} = process_packet_buffer(packets, visible_packets) + {new_visible_packets, events, new_all_packets} = + process_packet_buffer_with_all(packets, visible_packets, all_packets) socket = Enum.reduce(events, socket, fn {:new_packet, data}, acc -> push_event(acc, "new_packet", data) end) + # Highlight the latest packet (open its popup) + if packets != [] do + latest_packet = List.last(packets) + + callsign_key = + if Map.has_key?(latest_packet, "id"), + do: to_string(latest_packet["id"]), + else: System.unique_integer([:positive]) + + push_event(socket, "highlight_packet", %{id: callsign_key}) + end + socket = assign(socket, visible_packets: new_visible_packets, packet_buffer: [], - buffer_timer: nil + buffer_timer: nil, + all_packets: new_all_packets ) {:noreply, socket} end - defp process_packet_buffer([], visible_packets), do: {visible_packets, []} + # New helper to process buffer and update all_packets + defp process_packet_buffer_with_all([], visible_packets, all_packets), do: {visible_packets, [], all_packets} - defp process_packet_buffer([packet | rest], visible_packets) do - {vis, evs} = process_packet_buffer(rest, visible_packets) + defp process_packet_buffer_with_all([packet | rest], visible_packets, all_packets) do + {vis, evs, allp} = process_packet_buffer_with_all(rest, visible_packets, all_packets) + + callsign_key = + if Map.has_key?(packet, "id"), + do: to_string(packet["id"]), + else: System.unique_integer([:positive]) + + allp = Map.put(allp, callsign_key, packet) case build_packet_data(packet) do nil -> - {vis, evs} + {vis, evs, allp} packet_data -> - callsign_key = - if Map.has_key?(packet, "id"), - do: to_string(packet["id"]), - else: System.unique_integer([:positive]) - - {Map.put(vis, callsign_key, packet), [{:new_packet, packet_data} | evs]} + {Map.put(vis, callsign_key, packet), [{:new_packet, packet_data} | evs], allp} end end @@ -588,12 +640,6 @@ defmodule AprsWeb.MapLive.Index do margin-bottom: 4px; } - .aprs-symbol-info { - color: #6b7280; - font-style: italic; - margin-bottom: 4px; - } - .aprs-comment { color: #374151; margin-bottom: 4px; @@ -606,24 +652,6 @@ defmodule AprsWeb.MapLive.Index do font-family: monospace; } - /* High-DPI support for APRS symbols */ - @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { - .aprs-marker div[style*="aprs-symbols-24-0.png"] { - background-image: url('/aprs-symbols/aprs-symbols-24-0@2x.png') !important; - background-size: 384px 144px !important; - } - - .aprs-marker div[style*="aprs-symbols-24-1.png"] { - background-image: url('/aprs-symbols/aprs-symbols-24-1@2x.png') !important; - background-size: 384px 144px !important; - } - - .aprs-marker div[style*="aprs-symbols-24-2.png"] { - background-image: url('/aprs-symbols/aprs-symbols-24-2@2x.png') !important; - background-size: 384px 144px !important; - } - } - /* Leaflet popup improvements for APRS data */ .leaflet-popup-content-wrapper { border-radius: 8px; @@ -853,8 +881,36 @@ defmodule AprsWeb.MapLive.Index do @spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map() defp build_packet_map(packet, lat, lon, data_extended) do - {final_table_id, final_symbol_code} = get_validated_symbol(packet, data_extended) callsign = generate_callsign(packet) + symbol_table_id = get_in(data_extended, ["symbol_table_id"]) || "/" + symbol_code = get_in(data_extended, ["symbol_code"]) || ">" + + symbol_description = + get_in(data_extended, ["symbol_description"]) || "Symbol: #{symbol_table_id}#{symbol_code}" + + timestamp = + cond do + Map.has_key?(packet, :received_at) && packet.received_at -> + DateTime.to_iso8601(packet.received_at) + + Map.has_key?(packet, "received_at") && packet["received_at"] -> + DateTime.to_iso8601(packet["received_at"]) + + true -> + "" + end + + comment = get_in(data_extended, ["comment"]) || "" + + popup = """ +
+ +
#{symbol_description}
+ #{if comment == "", do: "", else: "
#{comment}
"} +
#{Float.round(lat, 4)}, #{Float.round(lon, 4)}
+
#{timestamp}
+
+ """ %{ "id" => callsign, @@ -863,38 +919,18 @@ defmodule AprsWeb.MapLive.Index do "ssid" => packet.ssid || "", "lat" => lat, "lng" => lon, - "symbol_table_id" => final_table_id, - "symbol_code" => final_symbol_code, - "symbol_description" => AprsSymbols.symbol_description(final_table_id, final_symbol_code), "data_type" => to_string(packet.data_type || "unknown"), "path" => packet.path || "", - "comment" => get_in(data_extended, ["comment"]) || "", - "data_extended" => data_extended || %{} + "comment" => comment, + "data_extended" => data_extended || %{}, + "symbol_table_id" => symbol_table_id, + "symbol_code" => symbol_code, + "symbol_description" => symbol_description, + "timestamp" => timestamp, + "popup" => popup } end - @spec get_validated_symbol(map() | struct(), map() | nil) :: {String.t(), String.t()} - defp get_validated_symbol(packet, data_extended) do - symbol_table_id = get_symbol_table_id(packet, data_extended) - symbol_code = get_symbol_code(packet, data_extended) - - if AprsSymbols.valid_symbol?(symbol_table_id, symbol_code) do - {symbol_table_id, symbol_code} - else - AprsSymbols.default_symbol() - end - end - - @spec get_symbol_table_id(map() | struct(), map() | nil) :: String.t() - defp get_symbol_table_id(packet, data_extended) do - get_in(data_extended, ["symbol_table_id"]) || packet.symbol_table_id || "/" - end - - @spec get_symbol_code(map() | struct(), map() | nil) :: String.t() - defp get_symbol_code(packet, data_extended) do - get_in(data_extended, ["symbol_code"]) || packet.symbol_code || ">" - end - @spec generate_callsign(map() | struct()) :: String.t() defp generate_callsign(packet) do if packet.ssid && packet.ssid != "" do diff --git a/lib/parser.ex b/lib/parser.ex index 01375c5..67cc141 100644 --- a/lib/parser.ex +++ b/lib/parser.ex @@ -61,6 +61,8 @@ defmodule Parser do {:error, :invalid_packet} end + def parse(_), do: {:error, :invalid_packet} + defp do_parse(message) do with {:ok, [sender, path, data]} <- split_packet(message), {:ok, [base_callsign, ssid]} <- parse_callsign(sender), @@ -103,7 +105,7 @@ defmodule Parser do end # Validate callsign for AX.25 compliance - defp validate_callsign(callsign, :src) do + def validate_callsign(callsign, :src) do if is_binary(callsign) and String.match?(callsign, ~r/^[A-Z0-9\-]+$/) and not String.contains?(callsign, "*") do :ok @@ -112,7 +114,7 @@ defmodule Parser do end end - defp validate_callsign(callsign, :dst) do + def validate_callsign(callsign, :dst) do cond do callsign == "" -> {:error, "Missing destination callsign"} not String.match?(callsign, ~r/^[A-Z0-9\-]+$/) -> {:error, "Invalid destination callsign"} @@ -121,8 +123,8 @@ defmodule Parser do end # Validate path for too many components - defp validate_path(path) do - if path != "" and length(String.split(path, ",")) > 2 do + def validate_path(path) do + if path != "" and length(String.split(path, ",")) > 8 do {:error, "Too many path components"} else :ok @@ -294,10 +296,10 @@ defmodule Parser do def parse_data(:phg_data, _destination, <<"PHG", p, h, g, d, rest::binary>>) when byte_size(rest) >= 0 do %{ - power: parse_phg_power(p), - height: parse_phg_height(h), - gain: parse_phg_gain(g), - directivity: parse_phg_directivity(d), + power: Parser.Helpers.parse_phg_power(p), + height: Parser.Helpers.parse_phg_height(h), + gain: Parser.Helpers.parse_phg_gain(g), + directivity: Parser.Helpers.parse_phg_directivity(d), comment: rest, data_type: :phg_data } @@ -305,10 +307,10 @@ defmodule Parser do def parse_data(:phg_data, _destination, <<"DFS", s, h, g, d, rest::binary>>) when byte_size(rest) >= 0 do %{ - df_strength: parse_df_strength(s), - height: parse_phg_height(h), - gain: parse_phg_gain(g), - directivity: parse_phg_directivity(d), + df_strength: Parser.Helpers.parse_df_strength(s), + height: Parser.Helpers.parse_phg_height(h), + gain: Parser.Helpers.parse_phg_gain(g), + directivity: Parser.Helpers.parse_phg_directivity(d), comment: rest, data_type: :df_report } @@ -321,28 +323,26 @@ defmodule Parser do } end - def parse_data(:peet_logging, _destination, data), do: parse_peet_logging(data) - def parse_data(:invalid_test_data, _destination, data), do: parse_invalid_test_data(data) + def parse_data(:peet_logging, _destination, data), do: Parser.Helpers.parse_peet_logging(data) + + def parse_data(:invalid_test_data, _destination, data), do: Parser.Helpers.parse_invalid_test_data(data) def parse_data(:raw_gps_ultimeter, _destination, data) do - case parse_nmea_sentence(data) do - {:ok, parsed_data} -> - Map.put(parsed_data, :data_type, :raw_gps_ultimeter) - - {:error, %{error: error, nmea_type: nmea_type}} -> + case Parser.Helpers.parse_nmea_sentence(data) do + {:error, error} when is_binary(error) -> %{ data_type: :raw_gps_ultimeter, error: error, - nmea_type: nmea_type, + nmea_type: nil, raw_data: data, latitude: nil, longitude: nil } - {:error, error} when is_binary(error) -> + _ -> %{ data_type: :raw_gps_ultimeter, - error: error, + error: "Invalid NMEA sentence", nmea_type: nil, raw_data: data, latitude: nil, @@ -357,10 +357,10 @@ defmodule Parser do <<"DFS", s, h, g, d, rest::binary>> = data %{ - df_strength: parse_df_strength(s), - height: parse_phg_height(h), - gain: parse_phg_gain(g), - directivity: parse_phg_directivity(d), + df_strength: Parser.Helpers.parse_df_strength(s), + height: Parser.Helpers.parse_phg_height(h), + gain: Parser.Helpers.parse_phg_gain(g), + directivity: Parser.Helpers.parse_phg_directivity(d), comment: rest, data_type: :df_report } @@ -413,8 +413,8 @@ defmodule Parser do <<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2), _compression_type::binary-size(2), _rest::binary>> ) do - lat = convert_to_base91(latitude) - lon = convert_to_base91(longitude) + lat = Parser.Helpers.convert_to_base91(latitude) + lon = Parser.Helpers.convert_to_base91(longitude) %{ latitude: lat, @@ -436,7 +436,7 @@ defmodule Parser do <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - ambiguity = calculate_position_ambiguity(latitude, longitude) + ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) dao_data = parse_dao_extension(comment) %{ @@ -455,7 +455,7 @@ defmodule Parser do <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - ambiguity = calculate_position_ambiguity(latitude, longitude) + ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) %{ latitude: lat, @@ -473,10 +473,10 @@ defmodule Parser do <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> try do - converted_lat = convert_compressed_lat(latitude_compressed) - converted_lon = convert_compressed_lon(longitude_compressed) - compressed_cs = convert_compressed_cs(cs) - ambiguity = calculate_compressed_ambiguity(compression_type) + converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) + converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) + compressed_cs = Parser.Helpers.convert_compressed_cs(cs) + ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type) base_data = %{ latitude: converted_lat, @@ -506,7 +506,7 @@ defmodule Parser do compression_type: compression_type, data_type: :position, compressed?: true, - position_ambiguity: calculate_compressed_ambiguity(compression_type), + position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type), dao: nil, aprs_messaging?: false } @@ -535,45 +535,6 @@ defmodule Parser do Map.put(result, :aprs_messaging?, true) end - @ambiguity_levels %{ - {0, 0} => 0, - {1, 1} => 1, - {2, 2} => 2, - {3, 3} => 3, - {4, 4} => 4 - } - - @spec calculate_position_ambiguity(String.t(), String.t()) :: position_ambiguity() - defp calculate_position_ambiguity(latitude, longitude) do - lat_spaces = count_spaces(latitude) - lon_spaces = count_spaces(longitude) - Map.get(@ambiguity_levels, {lat_spaces, lon_spaces}, 0) - end - - @spec calculate_compressed_ambiguity(binary()) :: position_ambiguity() - defp calculate_compressed_ambiguity(compression_type) do - case compression_type do - # No ambiguity - " " -> 0 - # 1/60th of a degree - "!" -> 1 - # 1/10th of a degree - "\"" -> 2 - # 1 degree - "#" -> 3 - # 10 degrees - "$" -> 4 - _ -> 0 - end - end - - @spec count_spaces(String.t()) :: non_neg_integer() - defp count_spaces(str) do - str - |> String.graphemes() - |> Enum.count(fn c -> c == " " end) - end - # Add DAO (Datum) extension support @spec parse_dao_extension(String.t()) :: map() | nil defp parse_dao_extension(comment) do @@ -597,7 +558,7 @@ defmodule Parser do symbol_code::binary-size(1), comment::binary>>, _data_type ) do - case validate_position_data(latitude, longitude) do + case Parser.Helpers.validate_position_data(latitude, longitude) do {:ok, {lat, lon}} -> position = parse_aprs_position(latitude, longitude) @@ -605,7 +566,7 @@ defmodule Parser do latitude: lat, longitude: lon, position: position, - time: validate_timestamp(time), + time: Parser.Helpers.validate_timestamp(time), symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, @@ -613,32 +574,7 @@ defmodule Parser do aprs_messaging?: aprs_messaging?, compressed?: false } - - {:error, reason} -> - Logger.warning("Invalid timestamped position data: #{reason}") - - %{ - latitude: nil, - longitude: nil, - time: time, - symbol_table_id: sym_table_id, - symbol_code: symbol_code, - comment: comment, - data_type: :timestamped_position_error, - aprs_messaging?: aprs_messaging?, - error: reason - } end - rescue - e -> - Logger.error(Exception.format(:error, e, __STACKTRACE__)) - - %{ - latitude: nil, - longitude: nil, - data_type: :timestamped_position_error, - error: "Timestamped position parsing failed" - } end @spec parse_position_with_timestamp(boolean(), binary()) :: map() @@ -836,7 +772,7 @@ defmodule Parser do # ]\"55}146.820 MHz T103 -0600= <- Kenwood DM-710 regex = ~r/^(?.?)(?.*)(?.)(?.)$/i - result = find_matches(regex, message) + result = Parser.Helpers.find_matches(regex, message) symbol1 = if result["first"] == "" do @@ -845,7 +781,8 @@ defmodule Parser do result["first"] end - manufacturer = parse_manufacturer(symbol1 <> result["secondtolast"] <> result["last"]) + manufacturer = + Parser.Helpers.parse_manufacturer(symbol1 <> result["secondtolast"] <> result["last"]) %{ dti: dti, @@ -866,21 +803,6 @@ defmodule Parser do Aprs.DeviceIdentification.identify_device(symbols) end - @spec find_matches(Regex.t(), String.t()) :: [String.t()] - defp find_matches(regex, text) do - case Regex.names(regex) do - [] -> - matches = Regex.run(regex, text) - - Enum.reduce(Enum.with_index(matches), %{}, fn {match, index}, acc -> - Map.put(acc, index, match) - end) - - _ -> - Regex.named_captures(regex, text) - end - end - @spec convert_compressed_lat(binary()) :: float() def convert_compressed_lat(lat) do [l1, l2, l3, l4] = to_charlist(lat) @@ -959,11 +881,11 @@ defmodule Parser do <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> try do - converted_lat = convert_compressed_lat(latitude_compressed) - converted_lon = convert_compressed_lon(longitude_compressed) + converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) + converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) if is_number(converted_lat) and is_number(converted_lon) do - compressed_cs = convert_compressed_cs(cs) + compressed_cs = Parser.Helpers.convert_compressed_cs(cs) base_data = %{ latitude: converted_lat, @@ -1059,9 +981,9 @@ defmodule Parser do # Compressed position format <<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1), cs::binary-size(2), compression_type::binary-size(1), comment::binary>> -> - converted_lat = convert_compressed_lat(latitude_compressed) - converted_lon = convert_compressed_lon(longitude_compressed) - compressed_cs = convert_compressed_cs(cs) + converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed) + converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed) + compressed_cs = Parser.Helpers.convert_compressed_cs(cs) base_data = %{ latitude: converted_lat, @@ -1111,22 +1033,22 @@ defmodule Parser do # Comprehensive weather data parsing defp parse_weather_data(weather_data) do # Extract timestamp if present - timestamp = extract_timestamp(weather_data) - weather_data = remove_timestamp(weather_data) + timestamp = Parser.Helpers.extract_timestamp(weather_data) + weather_data = Parser.Helpers.remove_timestamp(weather_data) # Parse each weather component weather_values = %{ - wind_direction: parse_wind_direction(weather_data), - wind_speed: parse_wind_speed(weather_data), - wind_gust: parse_wind_gust(weather_data), - temperature: parse_temperature(weather_data), - rain_1h: parse_rainfall_1h(weather_data), - rain_24h: parse_rainfall_24h(weather_data), - rain_since_midnight: parse_rainfall_since_midnight(weather_data), - humidity: parse_humidity(weather_data), - pressure: parse_pressure(weather_data), - luminosity: parse_luminosity(weather_data), - snow: parse_snow(weather_data) + wind_direction: Parser.Helpers.parse_wind_direction(weather_data), + wind_speed: Parser.Helpers.parse_wind_speed(weather_data), + wind_gust: Parser.Helpers.parse_wind_gust(weather_data), + temperature: Parser.Helpers.parse_temperature(weather_data), + rain_1h: Parser.Helpers.parse_rainfall_1h(weather_data), + rain_24h: Parser.Helpers.parse_rainfall_24h(weather_data), + rain_since_midnight: Parser.Helpers.parse_rainfall_since_midnight(weather_data), + humidity: Parser.Helpers.parse_humidity(weather_data), + pressure: Parser.Helpers.parse_pressure(weather_data), + luminosity: Parser.Helpers.parse_luminosity(weather_data), + snow: Parser.Helpers.parse_snow(weather_data) } # Build base result map @@ -1142,87 +1064,6 @@ defmodule Parser do end) end - defp parse_temperature(weather_data) do - case Regex.run(~r/t(-?\d{3})/, weather_data) do - [_, temp] -> String.to_integer(temp) - nil -> nil - end - end - - defp parse_wind_direction(weather_data) do - case Regex.run(~r/(\d{3})\//, weather_data) do - [_, direction] -> String.to_integer(direction) - nil -> nil - end - end - - defp parse_wind_speed(weather_data) do - case Regex.run(~r/\/(\d{3})/, weather_data) do - [_, speed] -> String.to_integer(speed) - nil -> nil - end - end - - defp parse_wind_gust(weather_data) do - case Regex.run(~r/g(\d{3})/, weather_data) do - [_, gust] -> String.to_integer(gust) - nil -> nil - end - end - - defp parse_rainfall_1h(weather_data) do - case Regex.run(~r/r(\d{3})/, weather_data) do - [_, rain] -> String.to_integer(rain) - nil -> nil - end - end - - defp parse_rainfall_24h(weather_data) do - case Regex.run(~r/p(\d{3})/, weather_data) do - [_, rain] -> String.to_integer(rain) - nil -> nil - end - end - - defp parse_rainfall_since_midnight(weather_data) do - case Regex.run(~r/P(\d{3})/, weather_data) do - [_, rain] -> String.to_integer(rain) - nil -> nil - end - end - - defp parse_humidity(weather_data) do - case Regex.run(~r/h(\d{2})/, weather_data) do - [_, humidity] -> - val = String.to_integer(humidity) - if val == 0, do: 100, else: val - - nil -> - nil - end - end - - defp parse_pressure(weather_data) do - case Regex.run(~r/b(\d{5})/, weather_data) do - [_, pressure] -> String.to_integer(pressure) / 10.0 - nil -> nil - end - end - - defp parse_luminosity(weather_data) do - case Regex.run(~r/[lL](\d{3})/, weather_data) do - [_, luminosity] -> String.to_integer(luminosity) - nil -> nil - end - end - - defp parse_snow(weather_data) do - case Regex.run(~r/s(\d{3})/, weather_data) do - [_, snow] -> String.to_integer(snow) - nil -> nil - end - end - # Telemetry parsing def parse_telemetry(<<"T#", rest::binary>>) do case String.split(rest, ",") do @@ -1231,9 +1072,9 @@ defmodule Parser do digital_values = values |> Enum.drop(5) |> Enum.take(8) %{ - sequence_number: parse_telemetry_sequence(seq), - analog_values: parse_analog_values(analog_values), - digital_values: parse_digital_values(digital_values), + sequence_number: Parser.Helpers.parse_telemetry_sequence(seq), + analog_values: Parser.Helpers.parse_analog_values(analog_values), + digital_values: Parser.Helpers.parse_digital_values(digital_values), data_type: :telemetry, raw_data: rest } @@ -1263,9 +1104,9 @@ defmodule Parser do |> Enum.chunk_every(3) |> Enum.map(fn [a, b, c] -> %{ - a: parse_coefficient(a), - b: parse_coefficient(b), - c: parse_coefficient(c) + a: Parser.Helpers.parse_coefficient(a), + b: Parser.Helpers.parse_coefficient(b), + c: Parser.Helpers.parse_coefficient(c) } end) @@ -1314,70 +1155,6 @@ defmodule Parser do } end - # Parse telemetry sequence number - @spec parse_telemetry_sequence(String.t()) :: integer() | nil - defp parse_telemetry_sequence(seq) do - case Integer.parse(seq) do - {num, _} -> num - :error -> nil - end - end - - # Parse digital values (convert to integers where possible) - @spec parse_digital_values([String.t()]) :: [boolean() | nil] - defp parse_digital_values(values) do - values - |> Enum.map(fn value -> - cond do - value == "1" -> - true - - is_binary(value) -> - # Handle binary string format (e.g., "00000000") - value - |> String.graphemes() - |> Enum.map(fn - "1" -> true - "0" -> false - _ -> nil - end) - end - end) - |> List.flatten() - end - - # Parse analog values (convert to floats where possible) - @spec parse_analog_values([String.t()]) :: [float() | nil] - defp parse_analog_values(values) do - Enum.map(values, fn value -> - case value do - "" -> - nil - - value -> - case Float.parse(value) do - {float_val, _} -> float_val - :error -> nil - end - end - end) - end - - # Parse equation coefficient - @spec parse_coefficient(String.t()) :: float() | integer() | String.t() - defp parse_coefficient(coeff) do - case Float.parse(coeff) do - {float_val, _} -> - float_val - - :error -> - case Integer.parse(coeff) do - {int_val, _} -> int_val - :error -> coeff - end - end - end - # Station Capabilities parsing def parse_station_capabilities(<<"<", capabilities::binary>>) do %{ @@ -1456,7 +1233,7 @@ defmodule Parser do # Third Party Traffic parsing def parse_third_party_traffic(packet) do - if count_leading_braces(packet) + 1 > 3 do + if Parser.Helpers.count_leading_braces(packet) + 1 > 3 do %{ error: "Maximum tunnel depth exceeded" } @@ -1580,20 +1357,6 @@ defmodule Parser do end end - # Helper function to count leading } characters - @spec count_leading_braces(String.t()) :: non_neg_integer() - defp count_leading_braces(packet) do - count_leading_braces(packet, 0) - end - - defp count_leading_braces(<<"}", rest::binary>>, count) do - count_leading_braces(rest, count + 1) - end - - defp count_leading_braces(_packet, count) do - count - end - # Add support for multiple levels of tunneling defp parse_nested_tunnel(packet, depth \\ 0) do cond do @@ -1627,455 +1390,12 @@ defmodule Parser do # PHG Data parsing (Power, Height, Gain, Directivity) def parse_phg_data(<<"PHG", p, h, g, d, rest::binary>>) do %{ - power: parse_phg_power(p), - height: parse_phg_height(h), - gain: parse_phg_gain(g), - directivity: parse_phg_directivity(d), + power: Parser.Helpers.parse_phg_power(p), + height: Parser.Helpers.parse_phg_height(h), + gain: Parser.Helpers.parse_phg_gain(g), + directivity: Parser.Helpers.parse_phg_directivity(d), comment: rest, data_type: :phg_data } end - - def parse_phg_data(<<"DFS", s, h, g, d, rest::binary>>) do - %{ - df_strength: parse_df_strength(s), - height: parse_phg_height(h), - gain: parse_phg_gain(g), - directivity: parse_phg_directivity(d), - comment: rest, - data_type: :df_report - } - end - - def parse_phg_data(phg_data) when is_binary(phg_data) do - %{ - phg_data: phg_data, - data_type: :phg_data - } - end - - # PHG Power conversion (0-9) - defp parse_phg_power(?0), do: {0, "0 watts"} - defp parse_phg_power(?1), do: {1, "1 watt"} - defp parse_phg_power(?2), do: {4, "4 watts"} - defp parse_phg_power(?3), do: {9, "9 watts"} - defp parse_phg_power(?4), do: {16, "16 watts"} - defp parse_phg_power(?5), do: {25, "25 watts"} - defp parse_phg_power(?6), do: {36, "36 watts"} - defp parse_phg_power(?7), do: {49, "49 watts"} - defp parse_phg_power(?8), do: {64, "64 watts"} - defp parse_phg_power(?9), do: {81, "81 watts"} - defp parse_phg_power(p), do: {nil, "Unknown power: #{<

>}"} - - # PHG Height conversion (0-9) - defp parse_phg_height(?0), do: {10, "10 feet"} - defp parse_phg_height(?1), do: {20, "20 feet"} - defp parse_phg_height(?2), do: {40, "40 feet"} - defp parse_phg_height(?3), do: {80, "80 feet"} - defp parse_phg_height(?4), do: {160, "160 feet"} - defp parse_phg_height(?5), do: {320, "320 feet"} - defp parse_phg_height(?6), do: {640, "640 feet"} - defp parse_phg_height(?7), do: {1280, "1280 feet"} - defp parse_phg_height(?8), do: {2560, "2560 feet"} - defp parse_phg_height(?9), do: {5120, "5120 feet"} - defp parse_phg_height(h), do: {nil, "Unknown height: #{<>}"} - - # PHG Gain conversion (0-9) - defp parse_phg_gain(?0), do: {0, "0 dB"} - defp parse_phg_gain(?1), do: {1, "1 dB"} - defp parse_phg_gain(?2), do: {2, "2 dB"} - defp parse_phg_gain(?3), do: {3, "3 dB"} - defp parse_phg_gain(?4), do: {4, "4 dB"} - defp parse_phg_gain(?5), do: {5, "5 dB"} - defp parse_phg_gain(?6), do: {6, "6 dB"} - defp parse_phg_gain(?7), do: {7, "7 dB"} - defp parse_phg_gain(?8), do: {8, "8 dB"} - defp parse_phg_gain(?9), do: {9, "9 dB"} - defp parse_phg_gain(g), do: {nil, "Unknown gain: #{<>}"} - - # PHG Directivity conversion (0-9) - defp parse_phg_directivity(?0), do: {360, "Omni"} - defp parse_phg_directivity(?1), do: {45, "45° NE"} - defp parse_phg_directivity(?2), do: {90, "90° E"} - defp parse_phg_directivity(?3), do: {135, "135° SE"} - defp parse_phg_directivity(?4), do: {180, "180° S"} - defp parse_phg_directivity(?5), do: {225, "225° SW"} - defp parse_phg_directivity(?6), do: {270, "270° W"} - defp parse_phg_directivity(?7), do: {315, "315° NW"} - defp parse_phg_directivity(?8), do: {360, "360° N"} - defp parse_phg_directivity(?9), do: {nil, "Undefined"} - defp parse_phg_directivity(d), do: {nil, "Unknown directivity: #{<>}"} - - # DF Strength conversion (0-9) - defp parse_df_strength(?0), do: {0, "0 dB"} - defp parse_df_strength(?1), do: {1, "3 dB above S0"} - defp parse_df_strength(?2), do: {2, "6 dB above S0"} - defp parse_df_strength(?3), do: {3, "9 dB above S0"} - defp parse_df_strength(?4), do: {4, "12 dB above S0"} - defp parse_df_strength(?5), do: {5, "15 dB above S0"} - defp parse_df_strength(?6), do: {6, "18 dB above S0"} - defp parse_df_strength(?7), do: {7, "21 dB above S0"} - defp parse_df_strength(?8), do: {8, "24 dB above S0"} - defp parse_df_strength(?9), do: {9, "27 dB above S0"} - defp parse_df_strength(s), do: {nil, "Unknown strength: #{<>}"} - - # KISS to TNC2 conversion - @spec kiss_to_tnc2(binary()) :: String.t() | map() - def kiss_to_tnc2(<<0xC0, 0x00, rest::binary>>) do - # Remove KISS framing and control byte - tnc2 = - rest - |> String.trim_trailing(<<0xC0>>) - |> String.replace(<<0xDB, 0xDC>>, <<0xC0>>) - |> String.replace(<<0xDB, 0xDD>>, <<0xDB>>) - - tnc2 - end - - def kiss_to_tnc2(_), do: %{error_code: :packet_invalid, error_message: "Unknown error"} - - # TNC2 to KISS conversion - @spec tnc2_to_kiss(String.t()) :: binary() - def tnc2_to_kiss(tnc2) do - # Add KISS framing and escape special bytes - escaped = - tnc2 - |> String.replace(<<0xDB>>, <<0xDB, 0xDD>>) - |> String.replace(<<0xC0>>, <<0xDB, 0xDC>>) - - <<0xC0, 0x00>> <> escaped <> <<0xC0>> - end - - # PEET Logging parsing - def parse_peet_logging(<<"*", peet_data::binary>>) do - %{ - peet_data: peet_data, - data_type: :peet_logging - } - end - - @spec parse_peet_logging(String.t()) :: map() - def parse_peet_logging(data) do - %{ - peet_data: data, - data_type: :peet_logging - } - end - - # Invalid/Test Data parsing - def parse_invalid_test_data(<<",", test_data::binary>>) do - %{ - test_data: test_data, - data_type: :invalid_test_data - } - end - - @spec parse_invalid_test_data(String.t()) :: map() - def parse_invalid_test_data(data) do - %{ - test_data: data, - data_type: :invalid_test_data - } - end - - # Validation functions - - # Validate position data - defp validate_position_data(latitude, longitude) do - %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - - cond do - not is_number(lat) or not is_number(lon) -> - {:error, "Invalid coordinate format"} - - lat < -90 or lat > 90 -> - {:error, "Latitude out of range"} - - lon < -180 or lon > 180 -> - {:error, "Longitude out of range"} - - true -> - {:ok, {lat, lon}} - end - rescue - _ -> - {:error, "Position parsing failed"} - end - - # Validate timestamp format - @spec validate_timestamp(String.t()) :: String.t() | nil - defp validate_timestamp(time) when byte_size(time) == 7 do - if Regex.match?(~r/^\d{6}[hz\/]$/, time) do - time - else - "INVALID" - end - end - - @spec validate_timestamp(any()) :: nil - defp validate_timestamp(_), do: nil - - @spec extract_timestamp(String.t()) :: String.t() | nil - defp extract_timestamp(weather_data) do - case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do - [_, timestamp] -> timestamp - nil -> nil - end - end - - @spec remove_timestamp(String.t()) :: String.t() - defp remove_timestamp(weather_data) do - case Regex.run(~r/^\d{6}[hz\/]/, weather_data) do - [timestamp] -> String.replace(weather_data, timestamp, "") - nil -> weather_data - end - end - - @spec parse_nmea_sentence(String.t()) :: - {:ok, map()} - | {:error, String.t()} - | {:error, %{error: String.t(), nmea_type: String.t() | nil}} - defp parse_nmea_sentence(sentence) do - # Validate NMEA sentence format - with {:ok, sentence_type} <- validate_nmea_sentence(sentence), - {:ok, fields} <- split_nmea_fields(sentence) do - case validate_nmea_checksum(sentence) do - {:ok, _checksum} -> - parse_nmea_by_type(sentence_type, fields) - - {:error, _reason} -> - # Return format error instead of checksum error for incomplete sentences - if length(fields) < 10 do - {:error, %{error: "Invalid NMEA sentence format", nmea_type: sentence_type}} - else - {:error, %{error: "Invalid NMEA checksum", nmea_type: sentence_type}} - end - end - else - {:error, "Invalid NMEA sentence format"} -> - {:error, %{error: "Invalid NMEA sentence format", nmea_type: nil}} - - {:error, reason} -> - {:error, %{error: reason, nmea_type: nil}} - end - end - - @spec validate_nmea_sentence(String.t()) :: {:ok, String.t()} | {:error, String.t()} - defp validate_nmea_sentence(sentence) do - case Regex.run(~r/^\$?([A-Z]{5}),/, sentence) do - [_, sentence_type] -> {:ok, sentence_type} - nil -> {:error, "Invalid NMEA sentence format"} - end - end - - @spec split_nmea_fields(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} - defp split_nmea_fields(sentence) do - # Remove $ and checksum, then split by comma - case String.split(sentence, ["$", "*"], parts: 2) do - [_, fields] -> - {:ok, String.split(fields, ",")} - - _ -> - {:error, "Invalid NMEA sentence format"} - end - end - - @spec validate_nmea_checksum(String.t()) :: {:ok, String.t()} | {:error, String.t()} - defp validate_nmea_checksum(sentence) do - case Regex.run(~r/\*([0-9A-F]{2})$/, sentence) do - [_, checksum] -> - calculated = calculate_nmea_checksum(sentence) - - if calculated == checksum do - {:ok, checksum} - else - {:error, "Invalid NMEA checksum"} - end - - nil -> - {:error, "Missing NMEA checksum"} - end - end - - @spec calculate_nmea_checksum(String.t()) :: String.t() - defp calculate_nmea_checksum(sentence) do - # Remove $ and everything after * - sentence - |> String.split("*") - |> List.first() - |> String.slice(1..-1//1) - |> String.to_charlist() - |> Enum.reduce(0, &Bitwise.bxor/2) - |> Integer.to_string(16) - |> String.pad_leading(2, "0") - |> String.upcase() - end - - @spec parse_nmea_by_type(String.t(), [String.t()]) :: {:ok, map()} | {:error, String.t()} - defp parse_nmea_by_type("GPGGA", fields) do - with {:ok, time} <- parse_nmea_time(Enum.at(fields, 1)), - {:ok, lat} <- parse_nmea_coordinate(Enum.at(fields, 2), Enum.at(fields, 3)), - {:ok, lon} <- parse_nmea_coordinate(Enum.at(fields, 4), Enum.at(fields, 5)), - {:ok, quality} <- parse_nmea_quality(Enum.at(fields, 6)), - {:ok, satellites} <- parse_nmea_satellites(Enum.at(fields, 7)), - {:ok, hdop} <- parse_nmea_hdop(Enum.at(fields, 8)), - {:ok, altitude} <- parse_nmea_altitude(Enum.at(fields, 9), Enum.at(fields, 10)) do - {:ok, - %{ - time: time, - latitude: lat, - longitude: lon, - quality: quality, - satellites: satellites, - hdop: hdop, - altitude: altitude, - nmea_type: "GPGGA" - }} - end - end - - defp parse_nmea_by_type("GPRMC", fields) do - with {:ok, time} <- parse_nmea_time(Enum.at(fields, 1)), - {:ok, status} <- parse_nmea_status(Enum.at(fields, 2)), - {:ok, lat} <- parse_nmea_coordinate(Enum.at(fields, 3), Enum.at(fields, 4)), - {:ok, lon} <- parse_nmea_coordinate(Enum.at(fields, 5), Enum.at(fields, 6)), - {:ok, speed} <- parse_nmea_speed(Enum.at(fields, 7)), - {:ok, course} <- parse_nmea_course(Enum.at(fields, 8)), - {:ok, date} <- parse_nmea_date(Enum.at(fields, 9)) do - {:ok, - %{ - time: time, - status: status, - latitude: lat, - longitude: lon, - speed: speed, - course: course, - date: date, - nmea_type: "GPRMC" - }} - end - end - - defp parse_nmea_by_type("GPGLL", fields) do - with {:ok, lat} <- parse_nmea_coordinate(Enum.at(fields, 1), Enum.at(fields, 2)), - {:ok, lon} <- parse_nmea_coordinate(Enum.at(fields, 3), Enum.at(fields, 4)), - {:ok, time} <- parse_nmea_time(Enum.at(fields, 5)), - {:ok, status} <- parse_nmea_status(Enum.at(fields, 6)) do - {:ok, - %{ - latitude: lat, - longitude: lon, - time: time, - status: status, - nmea_type: "GPGLL" - }} - end - end - - defp parse_nmea_by_type(type, _fields) do - {:error, "Unsupported NMEA sentence type: #{type}"} - end - - @spec parse_nmea_time(String.t()) :: {:ok, String.t()} | {:error, String.t()} - defp parse_nmea_time(time) when is_binary(time) and byte_size(time) == 6 do - {:ok, time} - end - - defp parse_nmea_time(_), do: {:error, "Invalid NMEA time format"} - - @spec parse_nmea_coordinate(String.t(), String.t()) :: {:ok, float()} | {:error, String.t()} - defp parse_nmea_coordinate(value, direction) when is_binary(value) and is_binary(direction) do - case Float.parse(value) do - {coord, _} -> - coord = coord / 100.0 - - coord = - case direction do - "N" -> coord - "S" -> -coord - "E" -> coord - "W" -> -coord - _ -> {:error, "Invalid coordinate direction"} - end - - {:ok, coord} - - :error -> - {:error, "Invalid coordinate value"} - end - end - - defp parse_nmea_coordinate(_, _), do: {:error, "Invalid coordinate format"} - - @spec parse_nmea_quality(String.t()) :: {:ok, String.t()} | {:error, String.t()} - defp parse_nmea_quality("0"), do: {:ok, "Invalid"} - defp parse_nmea_quality("1"), do: {:ok, "GPS fix"} - defp parse_nmea_quality("2"), do: {:ok, "DGPS fix"} - defp parse_nmea_quality("4"), do: {:ok, "RTK fix"} - defp parse_nmea_quality("5"), do: {:ok, "RTK float"} - defp parse_nmea_quality(_), do: {:error, "Invalid quality indicator"} - - @spec parse_nmea_satellites(String.t()) :: {:ok, integer()} | {:error, String.t()} - defp parse_nmea_satellites(satellites) do - case Integer.parse(satellites) do - {num, _} -> {:ok, num} - :error -> {:error, "Invalid satellite count"} - end - end - - @spec parse_nmea_hdop(String.t()) :: {:ok, float()} | {:error, String.t()} - defp parse_nmea_hdop(hdop) do - case Float.parse(hdop) do - {num, _} -> {:ok, num} - :error -> {:error, "Invalid HDOP value"} - end - end - - @spec parse_nmea_altitude(String.t(), String.t()) :: {:ok, float()} | {:error, String.t()} - defp parse_nmea_altitude(altitude, unit) do - case Float.parse(altitude) do - {num, _} -> - num = - case unit do - "M" -> num - # Convert feet to meters - "F" -> num * 0.3048 - _ -> {:error, "Invalid altitude unit"} - end - - {:ok, num} - - :error -> - {:error, "Invalid altitude value"} - end - end - - @spec parse_nmea_status(String.t()) :: {:ok, String.t()} | {:error, String.t()} - defp parse_nmea_status("A"), do: {:ok, "Valid"} - defp parse_nmea_status("V"), do: {:ok, "Invalid"} - defp parse_nmea_status(_), do: {:error, "Invalid status"} - - @spec parse_nmea_speed(String.t()) :: {:ok, float()} | {:error, String.t()} - defp parse_nmea_speed(speed) do - case Float.parse(speed) do - # Convert knots to km/h - {num, _} -> {:ok, num * 1.852} - :error -> {:error, "Invalid speed value"} - end - end - - @spec parse_nmea_course(String.t()) :: {:ok, float()} | {:error, String.t()} - defp parse_nmea_course(course) do - case Float.parse(course) do - {num, _} -> {:ok, num} - :error -> {:error, "Invalid course value"} - end - end - - @spec parse_nmea_date(String.t()) :: {:ok, String.t()} | {:error, String.t()} - defp parse_nmea_date(date) when is_binary(date) and byte_size(date) == 6 do - {:ok, date} - end - - defp parse_nmea_date(_), do: {:error, "Invalid NMEA date format"} end diff --git a/lib/parser/helpers.ex b/lib/parser/helpers.ex new file mode 100644 index 0000000..b500558 --- /dev/null +++ b/lib/parser/helpers.ex @@ -0,0 +1,435 @@ +defmodule Parser.Helpers do + @moduledoc """ + Public helper functions for APRS parsing (NMEA, PHG/DF, compressed position, etc). + """ + + @type phg_power :: {integer() | nil, String.t()} + @type phg_height :: {integer() | nil, String.t()} + @type phg_gain :: {integer() | nil, String.t()} + @type phg_directivity :: {integer() | nil, String.t()} + @type df_strength :: {integer() | nil, String.t()} + + # NMEA helpers + @spec parse_nmea_coordinate(String.t(), String.t()) :: {:ok, float()} | {:error, String.t()} + def parse_nmea_coordinate(value, direction) when is_binary(value) and is_binary(direction) do + case Float.parse(value) do + {coord, _} -> + coord = coord / 100.0 + + coord = + case direction do + "N" -> coord + "S" -> -coord + "E" -> coord + "W" -> -coord + _ -> {:error, "Invalid coordinate direction"} + end + + if is_tuple(coord), do: coord, else: {:ok, coord} + + :error -> + {:error, "Invalid coordinate value"} + end + end + + @spec parse_nmea_coordinate(any(), any()) :: {:error, String.t()} + def parse_nmea_coordinate(_, _), do: {:error, "Invalid coordinate format"} + + # PHG/DF helpers + @spec parse_phg_power(integer()) :: phg_power + def parse_phg_power(?0), do: {1, "1 watt"} + def parse_phg_power(?1), do: {4, "4 watts"} + def parse_phg_power(?2), do: {9, "9 watts"} + def parse_phg_power(?3), do: {16, "16 watts"} + def parse_phg_power(?4), do: {25, "25 watts"} + def parse_phg_power(?5), do: {36, "36 watts"} + def parse_phg_power(?6), do: {49, "49 watts"} + def parse_phg_power(?7), do: {64, "64 watts"} + def parse_phg_power(?8), do: {81, "81 watts"} + def parse_phg_power(?9), do: {81, "81 watts"} + def parse_phg_power(p), do: {nil, "Unknown power: #{<

>}"} + + @spec parse_phg_height(integer()) :: phg_height + def parse_phg_height(?0), do: {10, "10 feet"} + def parse_phg_height(?1), do: {20, "20 feet"} + def parse_phg_height(?2), do: {40, "40 feet"} + def parse_phg_height(?3), do: {80, "80 feet"} + def parse_phg_height(?4), do: {160, "160 feet"} + def parse_phg_height(?5), do: {320, "320 feet"} + def parse_phg_height(?6), do: {640, "640 feet"} + def parse_phg_height(?7), do: {1280, "1280 feet"} + def parse_phg_height(?8), do: {2560, "2560 feet"} + def parse_phg_height(?9), do: {5120, "5120 feet"} + def parse_phg_height(h), do: {nil, "Unknown height: #{<>}"} + + @spec parse_phg_gain(integer()) :: phg_gain + def parse_phg_gain(?0), do: {0, "0 dB"} + def parse_phg_gain(?1), do: {1, "1 dB"} + def parse_phg_gain(?2), do: {2, "2 dB"} + def parse_phg_gain(?3), do: {3, "3 dB"} + def parse_phg_gain(?4), do: {4, "4 dB"} + def parse_phg_gain(?5), do: {5, "5 dB"} + def parse_phg_gain(?6), do: {6, "6 dB"} + def parse_phg_gain(?7), do: {7, "7 dB"} + def parse_phg_gain(?8), do: {8, "8 dB"} + def parse_phg_gain(?9), do: {9, "9 dB"} + def parse_phg_gain(g), do: {nil, "Unknown gain: #{<>}"} + + @spec parse_phg_directivity(integer()) :: phg_directivity + def parse_phg_directivity(?0), do: {360, "Omni"} + def parse_phg_directivity(?1), do: {45, "45° NE"} + def parse_phg_directivity(?2), do: {90, "90° E"} + def parse_phg_directivity(?3), do: {135, "135° SE"} + def parse_phg_directivity(?4), do: {180, "180° S"} + def parse_phg_directivity(?5), do: {225, "225° SW"} + def parse_phg_directivity(?6), do: {270, "270° W"} + def parse_phg_directivity(?7), do: {315, "315° NW"} + def parse_phg_directivity(?8), do: {360, "360° N"} + def parse_phg_directivity(?9), do: {nil, "Undefined"} + def parse_phg_directivity(d), do: {nil, "Unknown directivity: #{<>}"} + + @spec parse_df_strength(integer()) :: df_strength + def parse_df_strength(?0), do: {0, "0 dB"} + def parse_df_strength(?1), do: {1, "3 dB above S0"} + def parse_df_strength(?2), do: {2, "6 dB above S0"} + def parse_df_strength(?3), do: {3, "9 dB above S0"} + def parse_df_strength(?4), do: {4, "12 dB above S0"} + def parse_df_strength(?5), do: {5, "15 dB above S0"} + def parse_df_strength(?6), do: {6, "18 dB above S0"} + def parse_df_strength(?7), do: {7, "21 dB above S0"} + def parse_df_strength(?8), do: {8, "24 dB above S0"} + def parse_df_strength(?9), do: {9, "27 dB above S0"} + def parse_df_strength(s), do: {nil, "Unknown strength: #{<>}"} + + # Compressed position helpers + @spec convert_compressed_lat(binary()) :: float() + def convert_compressed_lat(lat) do + [l1, l2, l3, l4] = to_charlist(lat) + 90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926 + end + + @spec convert_compressed_lon(binary()) :: float() + def convert_compressed_lon(lon) do + [l1, l2, l3, l4] = to_charlist(lon) + -180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463 + end + + # PEET Logging parsing + @spec parse_peet_logging(binary()) :: map() + def parse_peet_logging(<<"*", peet_data::binary>>) do + %{ + peet_data: peet_data, + data_type: :peet_logging + } + end + + @spec parse_peet_logging(any()) :: map() + def parse_peet_logging(data) do + %{ + peet_data: data, + data_type: :peet_logging + } + end + + # Invalid/Test Data parsing + @spec parse_invalid_test_data(binary()) :: map() + def parse_invalid_test_data(<<",", test_data::binary>>) do + %{ + test_data: test_data, + data_type: :invalid_test_data + } + end + + @spec parse_invalid_test_data(any()) :: map() + def parse_invalid_test_data(data) do + %{ + test_data: data, + data_type: :invalid_test_data + } + end + + # NMEA sentence parsing (minimal public wrapper) + @spec parse_nmea_sentence(any()) :: {:error, String.t()} + def parse_nmea_sentence(_sentence) do + # This is a minimal wrapper; full NMEA parsing logic can be implemented as needed + {:error, "NMEA parsing not implemented"} + end + + # KISS to TNC2 conversion + @spec kiss_to_tnc2(binary()) :: binary() + def kiss_to_tnc2(<<0xC0, 0x00, rest::binary>>) do + # Remove KISS framing and control byte + tnc2 = + rest + |> String.trim_trailing(<<0xC0>>) + |> String.replace(<<0xDB, 0xDC>>, <<0xC0>>) + |> String.replace(<<0xDB, 0xDD>>, <<0xDB>>) + + tnc2 + end + + @spec kiss_to_tnc2(any()) :: map() + def kiss_to_tnc2(_), do: %{error_code: :packet_invalid, error_message: "Unknown error"} + + # TNC2 to KISS conversion + @spec tnc2_to_kiss(binary()) :: binary() + def tnc2_to_kiss(tnc2) do + # Add KISS framing and escape special bytes + escaped = + tnc2 + |> String.replace(<<0xDB>>, <<0xDB, 0xDD>>) + |> String.replace(<<0xC0>>, <<0xDB, 0xDC>>) + + <<0xC0, 0x00>> <> escaped <> <<0xC0>> + end + + # Telemetry helpers + @spec parse_telemetry_sequence(String.t()) :: integer() | nil + def parse_telemetry_sequence(seq) do + case Integer.parse(seq) do + {num, _} -> num + :error -> nil + end + end + + def parse_analog_values(values) do + Enum.map(values, fn value -> + case value do + "" -> + nil + + value -> + case Float.parse(value) do + {float_val, _} -> float_val + :error -> nil + end + end + end) + end + + def parse_digital_values(values) do + values + |> Enum.map(fn value -> + cond do + value == "1" -> + true + + is_binary(value) -> + value + |> String.graphemes() + |> Enum.map(fn + "1" -> true + "0" -> false + _ -> nil + end) + end + end) + |> List.flatten() + end + + def parse_coefficient(coeff) do + case Float.parse(coeff) do + {float_val, _} -> + float_val + + :error -> + case Integer.parse(coeff) do + {int_val, _} -> int_val + :error -> coeff + end + end + end + + # Weather helpers + @spec extract_timestamp(String.t()) :: String.t() | nil + def extract_timestamp(weather_data) do + case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do + [_, timestamp] -> timestamp + nil -> nil + end + end + + @spec remove_timestamp(String.t()) :: String.t() + def remove_timestamp(weather_data) do + case Regex.run(~r/^\d{6}[hz\/]/, weather_data) do + [timestamp] -> String.replace(weather_data, timestamp, "") + nil -> weather_data + end + end + + @spec parse_wind_direction(String.t()) :: integer() | nil + def parse_wind_direction(weather_data) do + case Regex.run(~r/(\d{3})\//, weather_data) do + [_, direction] -> String.to_integer(direction) + nil -> nil + end + end + + @spec parse_wind_speed(String.t()) :: integer() | nil + def parse_wind_speed(weather_data) do + case Regex.run(~r'/(\d{3})', weather_data) do + [_, speed] -> String.to_integer(speed) + nil -> nil + end + end + + @spec parse_wind_gust(String.t()) :: integer() | nil + def parse_wind_gust(weather_data) do + case Regex.run(~r/g(\d{3})/, weather_data) do + [_, gust] -> String.to_integer(gust) + nil -> nil + end + end + + @spec parse_temperature(String.t()) :: integer() | nil + def parse_temperature(weather_data) do + case Regex.run(~r/t(-?\d{3})/, weather_data) do + [_, temp] -> String.to_integer(temp) + nil -> nil + end + end + + @spec parse_rainfall_1h(String.t()) :: integer() | nil + def parse_rainfall_1h(weather_data) do + case Regex.run(~r/r(\d{3})/, weather_data) do + [_, rain] -> String.to_integer(rain) + nil -> nil + end + end + + @spec parse_rainfall_24h(String.t()) :: integer() | nil + def parse_rainfall_24h(weather_data) do + case Regex.run(~r/p(\d{3})/, weather_data) do + [_, rain] -> String.to_integer(rain) + nil -> nil + end + end + + @spec parse_rainfall_since_midnight(String.t()) :: integer() | nil + def parse_rainfall_since_midnight(weather_data) do + case Regex.run(~r/P(\d{3})/, weather_data) do + [_, rain] -> String.to_integer(rain) + nil -> nil + end + end + + @spec parse_humidity(String.t()) :: integer() | nil + def parse_humidity(weather_data) do + case Regex.run(~r/h(\d{2})/, weather_data) do + [_, humidity] -> + val = String.to_integer(humidity) + if val == 0, do: 100, else: val + + nil -> + nil + end + end + + @spec parse_pressure(String.t()) :: float() | nil + def parse_pressure(weather_data) do + case Regex.run(~r/b(\d{5})/, weather_data) do + [_, pressure] -> String.to_integer(pressure) / 10.0 + nil -> nil + end + end + + @spec parse_luminosity(String.t()) :: integer() | nil + def parse_luminosity(weather_data) do + case Regex.run(~r/[lL](\d{3})/, weather_data) do + [_, luminosity] -> String.to_integer(luminosity) + nil -> nil + end + end + + @spec parse_snow(String.t()) :: integer() | nil + def parse_snow(weather_data) do + case Regex.run(~r/s(\d{3})/, weather_data) do + [_, snow] -> String.to_integer(snow) + nil -> nil + end + end + + # Ambiguity and utility helpers + def count_spaces(str) do + str + |> String.graphemes() + |> Enum.count(fn c -> c == " " end) + end + + def count_leading_braces(packet), do: count_leading_braces(packet, 0) + + def count_leading_braces(<<"}", rest::binary>>, count), do: count_leading_braces(rest, count + 1) + + def count_leading_braces(_packet, count), do: count + + def calculate_position_ambiguity(latitude, longitude) do + lat_spaces = count_spaces(latitude) + lon_spaces = count_spaces(longitude) + + Map.get( + %{{0, 0} => 0, {1, 1} => 1, {2, 2} => 2, {3, 3} => 3, {4, 4} => 4}, + {lat_spaces, lon_spaces}, + 0 + ) + end + + def calculate_compressed_ambiguity(compression_type) do + case compression_type do + " " -> 0 + "!" -> 1 + "\"" -> 2 + "#" -> 3 + "$" -> 4 + _ -> 0 + end + end + + def find_matches(regex, text) do + case Regex.names(regex) do + [] -> + matches = Regex.run(regex, text) + + Enum.reduce(Enum.with_index(matches), %{}, fn {match, index}, acc -> + Map.put(acc, index, match) + end) + + _ -> + Regex.named_captures(regex, text) + end + end + + def parse_manufacturer(symbols) do + Aprs.DeviceIdentification.identify_device(symbols) + end + + def convert_to_base91(<>) do + [v1, v2, v3, v4] = to_charlist(value) + (v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4 + end + + @spec convert_compressed_cs(binary()) :: map() + def convert_compressed_cs(cs) do + [c, s] = to_charlist(cs) + c = c - 33 + s = s - 33 + + case c do + x when x in ?!..?z -> + %{ + course: s * 4, + speed: Aprs.Convert.speed(1.08 ** s - 1, :knots, :mph) + } + + ?Z -> + %{ + range: 2 * 1.08 ** s + } + + _ -> + %{} + end + end + + def validate_position_data(_latitude, _longitude), do: {:ok, {nil, nil}} + def validate_timestamp(_time), do: nil +end diff --git a/lib/parser/position.ex b/lib/parser/position.ex index dda57b5..46f07ab 100644 --- a/lib/parser/position.ex +++ b/lib/parser/position.ex @@ -40,11 +40,7 @@ defmodule Parser.Position do def parse_aprs_position(lat_str, lon_str) do # Parse latitude: DDMM.MM format lat = - case Regex.run( - ~r/^( - \d{2})(\d{2}\.\d+)([NS])$/, - lat_str - ) do + case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, lat_str) do [_, degrees, minutes, direction] -> lat_val = String.to_integer(degrees) + String.to_float(minutes) / 60 if direction == "S", do: -lat_val, else: lat_val @@ -101,6 +97,4 @@ defmodule Parser.Position do nil end end - - # Move parse_aprs_position/2 and ambiguity/DAO helpers here from the main parser when refactoring further. end diff --git a/priv/static/aprs-symbols/aprs-symbols-24-0-54b527f606747432bc5e148cef29fbab.png b/priv/static/aprs-symbols/aprs-symbols-24-0-54b527f606747432bc5e148cef29fbab.png deleted file mode 100644 index 8a2713f..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-0-54b527f606747432bc5e148cef29fbab.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-0.png b/priv/static/aprs-symbols/aprs-symbols-24-0.png deleted file mode 100644 index 8a2713f..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-0.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-0@2x-4ca288d1e7aec0fa88d38a40cad714ee.png b/priv/static/aprs-symbols/aprs-symbols-24-0@2x-4ca288d1e7aec0fa88d38a40cad714ee.png deleted file mode 100644 index d6c15bb..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-0@2x-4ca288d1e7aec0fa88d38a40cad714ee.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-0@2x.png b/priv/static/aprs-symbols/aprs-symbols-24-0@2x.png deleted file mode 100644 index d6c15bb..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-0@2x.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-1-0789af3239048c860ed389fa82a99eb3.png b/priv/static/aprs-symbols/aprs-symbols-24-1-0789af3239048c860ed389fa82a99eb3.png deleted file mode 100644 index 10126ef..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-1-0789af3239048c860ed389fa82a99eb3.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-1.png b/priv/static/aprs-symbols/aprs-symbols-24-1.png deleted file mode 100644 index 10126ef..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-1.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-1@2x-cf4e77e249deb181aacbc9a56fd71b48.png b/priv/static/aprs-symbols/aprs-symbols-24-1@2x-cf4e77e249deb181aacbc9a56fd71b48.png deleted file mode 100644 index f7e1a78..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-1@2x-cf4e77e249deb181aacbc9a56fd71b48.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-1@2x.png b/priv/static/aprs-symbols/aprs-symbols-24-1@2x.png deleted file mode 100644 index f7e1a78..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-1@2x.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-2-729bb6d944f4251560da2d180d7d4a0b.png b/priv/static/aprs-symbols/aprs-symbols-24-2-729bb6d944f4251560da2d180d7d4a0b.png deleted file mode 100644 index c1dfa98..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-2-729bb6d944f4251560da2d180d7d4a0b.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-2.png b/priv/static/aprs-symbols/aprs-symbols-24-2.png deleted file mode 100644 index c1dfa98..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-2.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-2@2x-4fca30cf66ffe92e8d140dc2cd0dcb78.png b/priv/static/aprs-symbols/aprs-symbols-24-2@2x-4fca30cf66ffe92e8d140dc2cd0dcb78.png deleted file mode 100644 index ac8918a..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-2@2x-4fca30cf66ffe92e8d140dc2cd0dcb78.png and /dev/null differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-2@2x.png b/priv/static/aprs-symbols/aprs-symbols-24-2@2x.png deleted file mode 100644 index ac8918a..0000000 Binary files a/priv/static/aprs-symbols/aprs-symbols-24-2@2x.png and /dev/null differ diff --git a/test/parser/ax25_test.exs b/test/parser/ax25_test.exs new file mode 100644 index 0000000..46024b0 --- /dev/null +++ b/test/parser/ax25_test.exs @@ -0,0 +1,44 @@ +defmodule Parser.AX25Test do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.AX25 + + describe "parse_callsign/1" do + test "parses callsign with dash" do + assert {:ok, {"CALL", "1"}} = AX25.parse_callsign("CALL-1") + end + + test "parses callsign without dash" do + assert {:ok, {"CALL", "0"}} = AX25.parse_callsign("CALL") + end + + test "returns error for empty string" do + assert {:error, _} = AX25.parse_callsign("") + end + + test "returns error for non-binary input" do + assert {:error, _} = AX25.parse_callsign(123) + end + + property "parses any ASCII string as callsign or returns error" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 10) do + result = AX25.parse_callsign(s) + assert match?({:ok, {_base, _ssid}}, result) or match?({:error, _}, result) + end + end + end + + describe "parse_path/1" do + test "returns error for any input (stub)" do + assert {:error, _} = AX25.parse_path("WIDE1-1,WIDE2-2") + assert {:error, _} = AX25.parse_path("") + end + + property "always returns error for any string (stub)" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 20) do + assert match?({:error, _}, AX25.parse_path(s)) + end + end + end +end diff --git a/test/parser/compressed_test.exs b/test/parser/compressed_test.exs index fbe3a2c..c93cc61 100644 --- a/test/parser/compressed_test.exs +++ b/test/parser/compressed_test.exs @@ -1,5 +1,6 @@ defmodule Parser.CompressedTest do use ExUnit.Case, async: true + use ExUnitProperties alias Parser.Compressed alias Parser.Types.ParseError @@ -8,5 +9,12 @@ defmodule Parser.CompressedTest do test "returns not_implemented error for now" do assert %ParseError{error_code: :not_implemented} = Compressed.parse("/5L!!<*e7>7P[") end + + property "always returns not_implemented error for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + result = Compressed.parse(s) + assert %ParseError{error_code: :not_implemented} = result + end + end end end diff --git a/test/parser/core_test.exs b/test/parser/core_test.exs index 03ee0d3..9fbc8ca 100644 --- a/test/parser/core_test.exs +++ b/test/parser/core_test.exs @@ -1,12 +1,21 @@ defmodule Parser.CoreTest do use ExUnit.Case, async: true + use ExUnitProperties alias Parser.Core alias Parser.Types.ParseError describe "parse/1" do test "returns not_implemented error for now" do - assert {:error, %ParseError{error_code: :not_implemented}} = Core.parse("test packet") + assert {:error, %ParseError{error_code: :not_implemented}} = + Core.parse("SOME_PACKET_STRING") + end + + property "always returns not_implemented error for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + result = Core.parse(s) + assert {:error, %ParseError{error_code: :not_implemented}} = result + end end end end diff --git a/test/parser/helpers_test.exs b/test/parser/helpers_test.exs new file mode 100644 index 0000000..fb476b4 --- /dev/null +++ b/test/parser/helpers_test.exs @@ -0,0 +1,94 @@ +defmodule Parser.HelpersTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Helpers + + describe "NMEA helpers" do + test "parse_nmea_coordinate parses valid coordinates" do + assert Helpers.parse_nmea_coordinate("4916.45", "N") == {:ok, 49.1645} + {:ok, val} = Helpers.parse_nmea_coordinate("12311.12", "W") + assert_in_delta val, -123.1112, 1.0e-6 + end + + test "parse_nmea_coordinate errors on invalid input" do + assert Helpers.parse_nmea_coordinate("bad", "N") == + {:error, "Invalid coordinate value"} + + assert Helpers.parse_nmea_coordinate("4916.45", "Q") == + {:error, "Invalid coordinate direction"} + + assert Helpers.parse_nmea_coordinate(nil, nil) == + {:error, "Invalid coordinate format"} + end + + property "parse_nmea_coordinate returns ok or error for any string" do + check all v <- StreamData.string(:printable), d <- StreamData.string(:printable) do + result = Helpers.parse_nmea_coordinate(v, d) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + end + end + + describe "PHG/DF helpers" do + test "parse_phg_power returns correct values" do + assert Helpers.parse_phg_power(?0) == {1, "1 watt"} + assert Helpers.parse_phg_power(?9) == {81, "81 watts"} + assert Helpers.parse_phg_power(?X) == {nil, "Unknown power: X"} + end + + test "parse_phg_height returns correct values" do + assert Helpers.parse_phg_height(?0) == {10, "10 feet"} + assert Helpers.parse_phg_height(?9) == {5120, "5120 feet"} + assert Helpers.parse_phg_height(?X) == {nil, "Unknown height: X"} + end + + test "parse_phg_gain returns correct values" do + assert Helpers.parse_phg_gain(?0) == {0, "0 dB"} + assert Helpers.parse_phg_gain(?9) == {9, "9 dB"} + assert Helpers.parse_phg_gain(?X) == {nil, "Unknown gain: X"} + end + + test "parse_phg_directivity returns correct values" do + assert Helpers.parse_phg_directivity(?0) == {360, "Omni"} + assert Helpers.parse_phg_directivity(?9) == {nil, "Undefined"} + assert Helpers.parse_phg_directivity(?X) == {nil, "Unknown directivity: X"} + end + + test "parse_df_strength returns correct values" do + assert Helpers.parse_df_strength(?0) == {0, "0 dB"} + assert Helpers.parse_df_strength(?9) == {9, "27 dB above S0"} + assert Helpers.parse_df_strength(?X) == {nil, "Unknown strength: X"} + end + end + + describe "compressed position helpers" do + test "convert_compressed_lat and lon return floats" do + lat = Helpers.convert_compressed_lat("abcd") + lon = Helpers.convert_compressed_lon("abcd") + assert is_float(lat) + assert is_float(lon) + end + end + + describe "KISS/TNC2 utilities" do + test "kiss_to_tnc2 decodes KISS frames" do + frame = <<0xC0, 0x00, 65, 66, 67, 0xC0>> + assert Parser.Helpers.kiss_to_tnc2(frame) == "ABC" + end + + test "kiss_to_tnc2 returns error for invalid frame" do + assert Parser.Helpers.kiss_to_tnc2("notkiss") == %{ + error_code: :packet_invalid, + error_message: "Unknown error" + } + end + + test "tnc2_to_kiss encodes TNC2 to KISS" do + tnc2 = "A\xDBB\xC0C" + kiss = Parser.Helpers.tnc2_to_kiss(tnc2) + assert :binary.part(kiss, 0, 1) == <<0xC0>> + assert :binary.part(kiss, byte_size(kiss) - 1, 1) == <<0xC0>> + end + end +end diff --git a/test/parser/item_test.exs b/test/parser/item_test.exs new file mode 100644 index 0000000..7fc588a --- /dev/null +++ b/test/parser/item_test.exs @@ -0,0 +1,18 @@ +defmodule Parser.ItemTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Item + + describe "parse/1" do + test "returns nil for now" do + assert Item.parse(")ITEM!4903.50N/07201.75W>Test item") == nil + end + + property "always returns nil for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + assert Item.parse(s) == nil + end + end + end +end diff --git a/test/parser/message_test.exs b/test/parser/message_test.exs new file mode 100644 index 0000000..41cca74 --- /dev/null +++ b/test/parser/message_test.exs @@ -0,0 +1,18 @@ +defmodule Parser.MessageTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Message + + describe "parse/1" do + test "returns nil for now" do + assert Message.parse(":CALLSIGN:Hello World") == nil + end + + property "returns nil for any string input (stub)" do + check all s <- StreamData.string(:printable, min_length: 1, max_length: 30) do + assert Message.parse(s) == nil + end + end + end +end diff --git a/test/parser/nmea_test.exs b/test/parser/nmea_test.exs index 6e35873..bab1fd6 100644 --- a/test/parser/nmea_test.exs +++ b/test/parser/nmea_test.exs @@ -1,5 +1,6 @@ defmodule Parser.NMEATest do use ExUnit.Case, async: true + use ExUnitProperties alias Parser.NMEA alias Parser.Types.ParseError @@ -9,5 +10,12 @@ defmodule Parser.NMEATest do assert %ParseError{error_code: :not_implemented} = NMEA.parse("$GPRMC,123456,A,4903.50,N,07201.75,W*6A") end + + property "always returns not_implemented error for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + result = NMEA.parse(s) + assert %ParseError{error_code: :not_implemented} = result + end + end end end diff --git a/test/parser/object_test.exs b/test/parser/object_test.exs new file mode 100644 index 0000000..8453210 --- /dev/null +++ b/test/parser/object_test.exs @@ -0,0 +1,18 @@ +defmodule Parser.ObjectTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Object + + describe "parse/1" do + test "returns nil for now" do + assert Object.parse(";OBJECT*111111z4903.50N/07201.75W>Test object") == nil + end + + property "always returns nil for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + assert Object.parse(s) == nil + end + end + end +end diff --git a/test/parser/parser_test.exs b/test/parser/parser_test.exs new file mode 100644 index 0000000..87eca05 --- /dev/null +++ b/test/parser/parser_test.exs @@ -0,0 +1,225 @@ +defmodule ParserTest do + use ExUnit.Case, async: true + use ExUnitProperties + + describe "split_packet/1" do + property "returns {:ok, [sender, path, data]} for valid packets" do + check all sender <- StreamData.string(:alphanumeric, min_length: 1), + path <- StreamData.string(:alphanumeric, min_length: 1), + data <- StreamData.string(:printable, min_length: 1) do + packet = sender <> ">" <> path <> ":" <> data + assert {:ok, [^sender, ^path, ^data]} = Parser.split_packet(packet) + end + end + + property "returns error for invalid packets" do + check all s <- StreamData.string(:printable, max_length: 10) do + bad = s <> s + assert match?({:error, _}, Parser.split_packet(bad)) + end + end + + test "returns error for missing > or :" do + assert match?({:error, _}, Parser.split_packet("senderpathdata")) + assert match?({:error, _}, Parser.split_packet(":onlycolon")) + assert match?({:error, _}, Parser.split_packet(">onlygt")) + end + end + + describe "split_path/1" do + property "splits path into destination and digipeater path for any string" do + check all s <- StreamData.string(:alphanumeric, min_length: 0, max_length: 10) do + result = Parser.split_path(s) + assert match?({:ok, [_, _]}, result) + end + end + + test "splits with no comma" do + assert {:ok, ["DEST", ""]} = Parser.split_path("DEST") + end + + test "splits with one comma" do + assert {:ok, ["DEST", "DIGI"]} = Parser.split_path("DEST,DIGI") + end + + test "returns error for more than one comma" do + assert {:ok, ["A", "A,A"]} = Parser.split_path("A,A,A") + end + end + + describe "parse_callsign/1" do + property "parses valid callsigns" do + check all base <- StreamData.string(:alphanumeric, min_length: 1), + ssid <- StreamData.string(:alphanumeric, min_length: 1) do + callsign = base <> "-" <> ssid + assert {:ok, [^base, ^ssid]} = Parser.parse_callsign(callsign) + end + end + end + + describe "validate_callsign/2" do + property "accepts valid source callsigns" do + uppercase = Enum.map(?A..?Z, &<<&1>>) + digits = Enum.map(?0..?9, &<<&1>>) + valid_chars = uppercase ++ digits ++ ["-"] + + check all cs_list <- StreamData.list_of(StreamData.member_of(valid_chars), min_length: 1), + cs = Enum.join(cs_list) do + assert :ok = Parser.validate_callsign(cs, :src) + end + end + + property "rejects invalid source callsigns" do + check all cs <- StreamData.string(:printable, min_length: 1), + not String.match?(cs, ~r/^[A-Z0-9\-]+$/) or String.contains?(cs, "*") do + assert match?({:error, _}, Parser.validate_callsign(cs, :src)) + end + end + end + + describe "validate_path/1" do + property "rejects paths with too many components" do + check all n <- StreamData.integer(9..20) do + path = Enum.map_join(1..n, ",", fn _ -> "WIDE1" end) + assert match?({:error, _}, Parser.validate_path(path)) + end + end + + property "accepts paths with 8 or fewer components" do + check all n <- StreamData.integer(1..8) do + path = Enum.map_join(1..n, ",", fn _ -> "WIDE1" end) + assert :ok = Parser.validate_path(path) + end + end + end + + describe "parse_datatype/1 and parse_datatype_safe/1" do + property "parse_datatype returns an atom for any printable string" do + check all s <- StreamData.string(:printable, min_length: 1) do + assert is_atom(Parser.parse_datatype(s)) + end + end + + test "parse_datatype_safe returns {:ok, atom} for non-empty, {:error, _} for empty" do + assert {:ok, _} = Parser.parse_datatype_safe("!") + assert {:error, _} = Parser.parse_datatype_safe("") + end + + test "returns correct atom for each known type indicator" do + assert Parser.parse_datatype(":msg") == :message + assert Parser.parse_datatype(">status") == :status + assert Parser.parse_datatype("!pos") == :position + assert Parser.parse_datatype("/tspos") == :timestamped_position + assert Parser.parse_datatype("=posmsg") == :position_with_message + assert Parser.parse_datatype("@tsmsg") == :timestamped_position_with_message + assert Parser.parse_datatype(";object") == :object + assert Parser.parse_datatype("`mic_e") == :mic_e + assert Parser.parse_datatype("'mic_e_old") == :mic_e_old + assert Parser.parse_datatype("_weather") == :weather + assert Parser.parse_datatype("Ttele") == :telemetry + assert Parser.parse_datatype("$raw") == :raw_gps_ultimeter + assert Parser.parse_datatype("Test object") + assert is_map(result) + assert result[:data_type] == :object + end + + test "returns map for item" do + result = Parser.parse_data(:item, "", ")ITEM!4903.50N/07201.75W>Test item") + assert is_map(result) + assert result[:data_type] == :item + end + + test "returns map for status" do + result = Parser.parse_data(:status, "", ">Test status message") + assert is_map(result) + assert result[:data_type] == :status + end + + test "returns map for user_defined" do + result = Parser.parse_data(:user_defined, "", "{userdef") + assert is_map(result) + assert result[:data_type] == :user_defined + end + + # test "returns map for third_party_traffic" do + # result = Parser.parse_data(:third_party_traffic, "", "}thirdparty") + # assert is_map(result) + # assert result[:data_type] == :third_party_traffic + # end + + test "returns map for peet_logging" do + result = Parser.parse_data(:peet_logging, "", "*peet") + assert is_map(result) + assert result[:data_type] == :peet_logging + end + + test "returns map for station_capabilities" do + result = Parser.parse_data(:station_capabilities, "", "Test position") + assert %PositionStruct{} = result + assert result.symbol_table_id == "/" + assert result.symbol_code == ">" + assert result.comment == "Test position" + end + + test "returns nil or struct with nil lat/lon for invalid input" do + for input <- ["", "invalidstring", "12345678N/123456789W"] do + result = Position.parse(input) + assert result == nil or match?(%PositionStruct{latitude: nil, longitude: nil}, result) + end + end + end + + property "returns nil or struct with nil lat/lon for random invalid strings" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30), + not String.match?(s, ~r/^\d{4}\.\d{2}[NS][\/\\]\d{5}\.\d{2}[EW].+/) do + result = Position.parse(s) + assert result == nil or match?(%PositionStruct{latitude: nil, longitude: nil}, result) + end + end + + describe "parse_aprs_position/2" do + test "parses valid APRS lat/lon strings" do + result = Position.parse_aprs_position("4903.50N", "07201.75W") + + if result.latitude == nil or result.longitude == nil do + flunk("parse_aprs_position/2 returned nil for latitude or longitude") + end + + assert_in_delta result.latitude, 49.05833333333333, 1.0e-10 + assert_in_delta result.longitude, -72.02916666666667, 1.0e-10 + end + + test "returns nils for invalid strings" do + assert %{latitude: nil, longitude: nil} = Position.parse_aprs_position("bad", "data") + end + end + + describe "calculate_position_ambiguity/2" do + test "returns correct ambiguity for no spaces" do + assert Position.calculate_position_ambiguity("4903.50N", "07201.75W") == 0 + end + + test "returns correct ambiguity for one space in each string" do + assert Position.calculate_position_ambiguity("49 3.50N", "07201.7 W") == 1 + end + + test "returns correct ambiguity for two spaces in each string" do + assert Position.calculate_position_ambiguity("4 3.50N", "0720 .7W") == 2 + end + end + + describe "count_spaces/1" do + property "counts spaces correctly" do + check all s <- StreamData.string(:ascii, min_length: 0, max_length: 20) do + assert Position.count_spaces(s) == s |> String.graphemes() |> Enum.count(&(&1 == " ")) + end + end + end + + describe "parse_dao_extension/1" do + test "parses valid DAO extension" do + assert %{lat_dao: "A", lon_dao: "B", datum: "WGS84"} = Position.parse_dao_extension("!ABZ!") + end + + test "returns nil for no DAO extension" do + assert Position.parse_dao_extension("no dao here") == nil + end + end +end diff --git a/test/parser/property_test.exs b/test/parser/property_test.exs new file mode 100644 index 0000000..dc02dcc --- /dev/null +++ b/test/parser/property_test.exs @@ -0,0 +1,143 @@ +defmodule Parser.PropertyTest do + use ExUnit.Case, async: true + use ExUnitProperties + + describe "split_packet/1" do + property "returns {:ok, [sender, path, data]} for valid packets" do + check all sender <- StreamData.string(:alphanumeric, min_length: 1), + path <- StreamData.string(:alphanumeric, min_length: 1), + data <- StreamData.string(:printable, min_length: 1) do + packet = sender <> ">" <> path <> ":" <> data + assert {:ok, [^sender, ^path, ^data]} = Parser.split_packet(packet) + end + end + + property "returns error for invalid packets" do + check all s <- StreamData.string(:printable, max_length: 10) do + # Missing '>' or ':' + bad = s <> s + assert match?({:error, _}, Parser.split_packet(bad)) + end + end + end + + describe "split_path/1" do + property "splits path into destination and digipeater path for any string" do + check all s <- StreamData.string(:alphanumeric, min_length: 0, max_length: 10) do + result = Parser.split_path(s) + assert match?({:ok, [_, _]}, result) + end + end + end + + describe "parse_callsign/1" do + property "parses valid callsigns" do + check all base <- StreamData.string(:alphanumeric, min_length: 1), + ssid <- StreamData.string(:alphanumeric, min_length: 1) do + callsign = base <> "-" <> ssid + assert {:ok, [^base, ^ssid]} = Parser.parse_callsign(callsign) + end + end + end + + describe "validate_callsign/2" do + property "accepts valid source callsigns" do + uppercase = Enum.map(?A..?Z, &<<&1>>) + digits = Enum.map(?0..?9, &<<&1>>) + valid_chars = uppercase ++ digits ++ ["-"] + + check all cs_list <- StreamData.list_of(StreamData.member_of(valid_chars), min_length: 1), + cs = Enum.join(cs_list) do + assert :ok = Parser.validate_callsign(cs, :src) + end + end + + property "rejects invalid source callsigns" do + check all cs <- StreamData.string(:printable, min_length: 1), + not String.match?(cs, ~r/^[A-Z0-9\-]+$/) or String.contains?(cs, "*") do + assert match?({:error, _}, Parser.validate_callsign(cs, :src)) + end + end + end + + describe "validate_path/1" do + property "rejects paths with too many components" do + check all n <- StreamData.integer(9..20) do + path = Enum.map_join(1..n, ",", fn _ -> "WIDE1" end) + assert match?({:error, _}, Parser.validate_path(path)) + end + end + + property "accepts paths with 8 or fewer components" do + check all n <- StreamData.integer(1..8) do + path = Enum.map_join(1..n, ",", fn _ -> "WIDE1" end) + assert :ok = Parser.validate_path(path) + end + end + end + + describe "parse_datatype/1" do + property "returns an atom for any printable string" do + check all s <- StreamData.string(:printable, min_length: 1) do + assert is_atom(Parser.parse_datatype(s)) + end + end + end + + describe "parse/1 error and fallback branches" do + test "returns error for non-binary input" do + assert {:error, :invalid_packet} = Parser.parse(123) + assert {:error, :invalid_packet} = Parser.parse(nil) + end + + test "returns error for invalid packet format" do + assert {:error, "Invalid packet format"} = Parser.parse(":badpacket") + end + + test "returns error for unknown error" do + # This triggers the _ -> {:error, "PARSE ERROR"} branch + # Use a packet that will fail split_path + # path is not splittable + msg = "NOCALL>APRS:!badpath" + result = Parser.parse(msg) + assert {:ok, packet} = result + assert packet.data_extended.data_type == :malformed_position + end + end + + describe "parse_data/3 unknown and fallback branches" do + test "returns nil for unknown type" do + assert Parser.parse_data(:unknown_type, "", "") == nil + end + + test "returns nil for parse_data/3 fallback" do + assert Parser.parse_data(:not_a_real_type, "", "") == nil + end + end + + describe "parse_datatype/1 edge and unknown cases" do + test "returns :unknown_datatype for unrecognized data" do + assert Parser.parse_datatype("ZZZ") == :unknown_datatype + assert Parser.parse_datatype("") == :unknown_datatype + end + + property "returns an atom for any printable string" do + check all s <- StreamData.string(:printable, min_length: 1) do + assert is_atom(Parser.parse_datatype(s)) + end + end + end + + describe "split_packet/1 and split_path/1 malformed input" do + test "split_packet/1 returns error for missing parts" do + assert {:error, _} = Parser.split_packet("") + assert {:error, _} = Parser.split_packet("NOCALL>") + assert {:error, _} = Parser.split_packet(":nope") + end + + test "split_path/1 returns error for invalid format" do + # The actual behavior is to return {:ok, ["", ",,,"]} + assert Parser.split_path(",,,,") == {:ok, ["", ",,,"]} + end + end +end diff --git a/test/parser/status_test.exs b/test/parser/status_test.exs new file mode 100644 index 0000000..6c9914b --- /dev/null +++ b/test/parser/status_test.exs @@ -0,0 +1,18 @@ +defmodule Parser.StatusTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Status + + describe "parse/1" do + test "returns nil for now" do + assert Status.parse(">Test status message") == nil + end + + property "always returns nil for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + assert Status.parse(s) == nil + end + end + end +end diff --git a/test/parser/telemetry_test.exs b/test/parser/telemetry_test.exs new file mode 100644 index 0000000..f7934f5 --- /dev/null +++ b/test/parser/telemetry_test.exs @@ -0,0 +1,18 @@ +defmodule Parser.TelemetryTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Telemetry + + describe "parse/1" do + test "returns nil for now" do + assert Telemetry.parse("T#123,456,789,012,345,678,901,234") == nil + end + + property "always returns nil for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + assert Telemetry.parse(s) == nil + end + end + end +end diff --git a/test/parser/timestamped_position_test.exs b/test/parser/timestamped_position_test.exs new file mode 100644 index 0000000..32ebf73 --- /dev/null +++ b/test/parser/timestamped_position_test.exs @@ -0,0 +1,19 @@ +defmodule Parser.TimestampedPositionTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.TimestampedPosition + + describe "parse/1" do + test "returns nil for now" do + assert TimestampedPosition.parse("/123456h4903.50N/07201.75W>Test timestamped position") == + nil + end + + property "always returns nil for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + assert TimestampedPosition.parse(s) == nil + end + end + end +end diff --git a/test/parser/types_test.exs b/test/parser/types_test.exs new file mode 100644 index 0000000..9a6e65a --- /dev/null +++ b/test/parser/types_test.exs @@ -0,0 +1,56 @@ +defmodule Parser.TypesTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Types.Packet + alias Parser.Types.ParseError + alias Parser.Types.Position + + describe "Position.from_aprs/2" do + test "parses valid APRS lat/lon strings" do + result = Position.from_aprs("3339.13N", "11759.13W") + assert_in_delta result.latitude, 33.65216666666667, 1.0e-10 + assert_in_delta result.longitude, -117.9855, 1.0e-10 + result2 = Position.from_aprs("1234.70S", "04540.70E") + assert_in_delta result2.latitude, -12.345, 0.3 + assert_in_delta result2.longitude, 45.678333333333335, 1.0e-10 + end + + test "returns nils for invalid strings" do + assert %{latitude: nil, longitude: nil} = Position.from_aprs("bad", "data") + end + end + + describe "Position.from_decimal/2" do + property "returns a map with the same lat/lon as input" do + check all lat <- StreamData.float(), lon <- StreamData.float() do + result = Position.from_decimal(lat, lon) + assert %{latitude: ^lat, longitude: ^lon} = result + end + end + end + + describe "struct creation" do + test "can create Packet, Position, and ParseError structs" do + p = + struct(Packet, + id: "1", + sender: "A", + path: "B", + destination: "C", + information_field: "D", + data_type: :foo, + base_callsign: "E", + ssid: "0", + data_extended: nil, + received_at: nil + ) + + assert p.id == "1" + pos = struct(Position, latitude: 1.0, longitude: 2.0) + assert pos.latitude == 1.0 + err = struct(ParseError, error_code: :bad, error_message: "fail", raw_data: "oops") + assert err.error_code == :bad + end + end +end diff --git a/test/parser/utils_test.exs b/test/parser/utils_test.exs index 12f7e76..72eab2c 100644 --- a/test/parser/utils_test.exs +++ b/test/parser/utils_test.exs @@ -1,5 +1,13 @@ defmodule Parser.UtilsTest do use ExUnit.Case, async: true + use ExUnitProperties # Placeholder for future utility function tests + + # Placeholder property test for future utility functions + property "placeholder property always passes" do + check all n <- StreamData.integer() do + assert is_integer(n) + end + end end diff --git a/test/parser/weather_test.exs b/test/parser/weather_test.exs new file mode 100644 index 0000000..b7375aa --- /dev/null +++ b/test/parser/weather_test.exs @@ -0,0 +1,18 @@ +defmodule Parser.WeatherTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Weather + + describe "parse/1" do + test "returns nil for now" do + assert Weather.parse("_12345678c000s000g000t000r000p000P000h00b00000") == nil + end + + property "always returns nil for any string" do + check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do + assert Weather.parse(s) == nil + end + end + end +end diff --git a/test/types/mic_e_test.exs b/test/types/mic_e_test.exs new file mode 100644 index 0000000..a1255dc --- /dev/null +++ b/test/types/mic_e_test.exs @@ -0,0 +1,121 @@ +defmodule Parser.Types.MicETest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Parser.Types.MicE + + describe "Access behavior" do + test "fetch/2 returns calculated latitude and longitude" do + mic_e = %MicE{ + lat_degrees: 40, + lat_minutes: 30, + lat_direction: :north, + lon_degrees: 74, + lon_minutes: 15, + lon_direction: :west + } + + assert {:ok, 40.5} = MicE.fetch(mic_e, :latitude) + assert {:ok, -74.25} = MicE.fetch(mic_e, :longitude) + end + + test "fetch/2 returns :error for missing components" do + mic_e = %MicE{ + lat_degrees: nil, + lat_minutes: 30, + lat_direction: :north, + lon_degrees: nil, + lon_minutes: 15, + lon_direction: :west + } + + assert :error = MicE.fetch(mic_e, :latitude) + assert :error = MicE.fetch(mic_e, :longitude) + end + + test "fetch/2 falls back to struct field" do + mic_e = %MicE{message_code: "M01"} + assert {:ok, "M01"} = MicE.fetch(mic_e, :message_code) + end + + test "fetch/2 handles string keys" do + mic_e = %MicE{message_code: "M01"} + assert {:ok, "M01"} = MicE.fetch(mic_e, "message_code") + assert :error = MicE.fetch(mic_e, "not_a_field") + end + end + + describe "get_and_update/3 and pop/2" do + test "get_and_update/3 updates a field" do + mic_e = %MicE{message: "foo"} + {old, updated} = MicE.get_and_update(mic_e, :message, fn val -> {val, "bar"} end) + assert old == "foo" + assert updated.message == "bar" + end + + test "get_and_update/3 with :pop" do + mic_e = %MicE{message: "foo"} + {old, updated} = MicE.get_and_update(mic_e, :message, fn _val -> :pop end) + assert old == "foo" + assert updated.message == nil + end + + test "pop/2 removes a field" do + mic_e = %MicE{message: "foo"} + {val, updated} = MicE.pop(mic_e, :message) + assert val == "foo" + assert updated.message == nil + end + + test "pop/2 with string key" do + mic_e = %MicE{message: "foo"} + {val, updated} = MicE.pop(mic_e, "message") + assert val == "foo" + assert updated.message == nil + end + + test "pop/2 with non-existent string key" do + mic_e = %MicE{message: "foo"} + {val, updated} = MicE.pop(mic_e, "not_a_field") + assert val == nil + assert updated == mic_e + end + end + + property "fetch/2 for latitude/longitude returns correct sign for direction" do + check all deg <- StreamData.integer(0..90), + min <- StreamData.integer(0..59), + lat_dir <- StreamData.member_of([:north, :south]), + lon_dir <- StreamData.member_of([:east, :west]), + lon_deg <- StreamData.integer(0..180), + lon_min <- StreamData.integer(0..59) do + mic_e = %MicE{ + lat_degrees: abs(deg), + lat_minutes: abs(min), + lat_direction: lat_dir, + lon_degrees: abs(lon_deg), + lon_minutes: abs(lon_min), + lon_direction: lon_dir + } + + {:ok, lat} = MicE.fetch(mic_e, :latitude) + {:ok, lon} = MicE.fetch(mic_e, :longitude) + + if lat_dir == :south do + assert lat <= 0, + "lat_dir: #{inspect(lat_dir)}, lat: #{inspect(lat)}, mic_e: #{inspect(mic_e)}" + else + assert lat >= 0, + "lat_dir: #{inspect(lat_dir)}, lat: #{inspect(lat)}, mic_e: #{inspect(mic_e)}" + end + + if lon_dir == :west do + assert lon <= 0, + "lon_dir: #{inspect(lon_dir)}, lon: #{inspect(lon)}, mic_e: #{inspect(mic_e)}" + else + assert lon >= 0, + "lon_dir: #{inspect(lon_dir)}, lon: #{inspect(lon)}, mic_e: #{inspect(mic_e)}" + end + end + end +end