refactor
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
820
lib/parser.ex
435
lib/parser/helpers.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
44
test/parser/ax25_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
94
test/parser/helpers_test.exs
Normal 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
|
|
@ -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
|
||||
18
test/parser/message_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
18
test/parser/object_test.exs
Normal 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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
81
test/parser/position_test.exs
Normal 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
|
||||
143
test/parser/property_test.exs
Normal 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
|
||||
18
test/parser/status_test.exs
Normal 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
|
||||
18
test/parser/telemetry_test.exs
Normal 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
|
||||
19
test/parser/timestamped_position_test.exs
Normal 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
|
||||
56
test/parser/types_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
18
test/parser/weather_test.exs
Normal 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
|
|
@ -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
|
||||