This commit is contained in:
Graham McIntire 2025-06-19 15:20:18 -05:00
parent c1a4bb736c
commit 3903c1b26c
No known key found for this signature in database
40 changed files with 1650 additions and 1519 deletions

View file

@ -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,

View file

@ -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<string, string> = {
"/": "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;

View file

@ -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

View file

@ -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

View file

@ -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(<div class="#{css_class}" style="#{full_style}"></div>))
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

View file

@ -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 = """
<div class=\"aprs-popup\">
<div class=\"aprs-callsign\"><strong><a href=\"/#{callsign}\">#{callsign}</a></strong></div>
<div class=\"aprs-symbol-info\">#{symbol_description}</div>
#{if comment == "", do: "", else: "<div class=\\\"aprs-comment\\\">#{comment}</div>"}
<div class=\"aprs-coords\">#{Float.round(lat, 4)}, #{Float.round(lon, 4)}</div>
<div class=\"aprs-time\">#{timestamp}</div>
</div>
"""
%{
"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

File diff suppressed because it is too large Load diff

435
lib/parser/helpers.ex Normal file
View file

@ -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: #{<<p>>}"}
@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: #{<<h>>}"}
@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: #{<<g>>}"}
@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: #{<<d>>}"}
@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: #{<<s>>}"}
# 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(<<value::binary-size(4)>>) 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

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

44
test/parser/ax25_test.exs Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

18
test/parser/item_test.exs Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

225
test/parser/parser_test.exs Normal file
View file

@ -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("<cap") == :station_capabilities
assert Parser.parse_datatype("?query") == :query
assert Parser.parse_datatype("{userdef") == :user_defined
assert Parser.parse_datatype("}thirdparty") == :third_party_traffic
assert Parser.parse_datatype("%item") == :item
assert Parser.parse_datatype(")item") == :item
assert Parser.parse_datatype("*peet") == :peet_logging
assert Parser.parse_datatype(",test") == :invalid_test_data
assert Parser.parse_datatype("#DFSfoo") == :df_report
assert Parser.parse_datatype("#PHGfoo") == :phg_data
assert Parser.parse_datatype("#foo") == :phg_data
assert Parser.parse_datatype("Xunknown") == :unknown_datatype
end
end
describe "parse_data/3" do
test "returns nil for unknown type" do
assert Parser.parse_data(:unknown, "", "") == nil
end
test "returns nil for invalid test data" do
assert Parser.parse_data(:invalid_test_data, "", ",testdata")[:data_type] ==
:invalid_test_data
end
test "returns map for weather" do
result = Parser.parse_data(:weather, "", "_12345678c000s000g000t000r000p000P000h00b00000")
assert is_map(result)
assert result[:data_type] == :weather
end
test "returns map for telemetry" do
result = Parser.parse_data(:telemetry, "", "T#123,456,789,012,345,678,901,234")
assert is_map(result)
assert result[:data_type] == :telemetry
end
test "returns map for object" do
result = Parser.parse_data(:object, "", ";OBJECT*111111z4903.50N/07201.75W>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, "", "<cap")
assert is_map(result)
assert result[:data_type] == :station_capabilities
end
test "returns map for query" do
result = Parser.parse_data(:query, "", "?query")
assert is_map(result)
assert result[:data_type] == :query
end
test "returns map for df_report" do
result = Parser.parse_data(:df_report, "", "#DFS1234rest")
assert is_map(result)
assert result[:data_type] == :df_report
end
test "returns map for phg_data" do
result = Parser.parse_data(:phg_data, "", "#PHG1234rest")
assert is_map(result)
assert result[:data_type] == :phg_data
end
end
describe "parse/1" do
test "returns {:error, _} for obviously invalid input" do
assert match?({:error, _}, Parser.parse(""))
assert match?({:error, _}, Parser.parse("notapacket"))
end
end
end

View file

@ -1,5 +1,6 @@
defmodule Parser.PHGTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias Parser.PHG
alias Parser.Types.ParseError
@ -8,5 +9,12 @@ defmodule Parser.PHGTest do
test "returns not_implemented error for now" do
assert %ParseError{error_code: :not_implemented} = PHG.parse("#PHG2360")
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 = PHG.parse(s)
assert %ParseError{error_code: :not_implemented} = result
end
end
end
end

View file

@ -0,0 +1,81 @@
defmodule Parser.PositionTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias Parser.Position
alias Parser.Types.Position, as: PositionStruct
describe "parse/1" do
test "returns a Position struct for valid input" do
result = Position.parse("4903.50N/07201.75W>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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

121
test/types/mic_e_test.exs Normal file
View file

@ -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