Files
ham/protocol/aprs/position.go
2026-02-14 15:59:31 +01:00

314 lines
7.6 KiB
Go

package aprs
import (
"fmt"
"strconv"
"strings"
"git.maze.io/go/ham/util/maidenhead"
)
var (
// Position ambiguity replacement
disambiguation = []int{2, 3, 5, 6, 12, 13, 15, 16}
miceCodes = map[rune]map[int]string{
'0': {0: "0", 1: "0", 2: "S", 3: "0", 4: "E"},
'1': {0: "1", 1: "0", 2: "S", 3: "0", 4: "E"},
'2': {0: "2", 1: "0", 2: "S", 3: "0", 4: "E"},
'3': {0: "3", 1: "0", 2: "S", 3: "0", 4: "E"},
'4': {0: "4", 1: "0", 2: "S", 3: "0", 4: "E"},
'5': {0: "5", 1: "0", 2: "S", 3: "0", 4: "E"},
'6': {0: "6", 1: "0", 2: "S", 3: "0", 4: "E"},
'7': {0: "7", 1: "0", 2: "S", 3: "0", 4: "E"},
'8': {0: "8", 1: "0", 2: "S", 3: "0", 4: "E"},
'9': {0: "9", 1: "0", 2: "S", 3: "0", 4: "E"},
'A': {0: "0", 1: "1 (Custom)"},
'B': {0: "1", 1: "1 (Custom)"},
'C': {0: "2", 1: "1 (Custom)"},
'D': {0: "3", 1: "1 (Custom)"},
'E': {0: "4", 1: "1 (Custom)"},
'F': {0: "5", 1: "1 (Custom)"},
'G': {0: "6", 1: "1 (Custom)"},
'H': {0: "7", 1: "1 (Custom)"},
'I': {0: "8", 1: "1 (Custom)"},
'J': {0: "9", 1: "1 (Custom)"},
'K': {0: " ", 1: "1 (Custom)"},
'L': {0: " ", 1: "0", 2: "S", 3: "0", 4: "E"},
'P': {0: "0", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'Q': {0: "1", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'R': {0: "2", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'S': {0: "3", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'T': {0: "4", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'U': {0: "5", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'V': {0: "6", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'W': {0: "7", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'X': {0: "8", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'Y': {0: "9", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
'Z': {0: " ", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
}
miceMsgTypes = map[string]string{
"000": "Emergency",
"001 (Std)": "Priority",
"001 (Custom)": "Custom-6",
"010 (Std)": "Special",
"010 (Custom)": "Custom-5",
"011 (Std)": "Committed",
"011 (Custom)": "Custom-4",
"100 (Std)": "Returning",
"100 (Custom)": "Custom-3",
"101 (Std)": "In Service",
"101 (Custom)": "Custom-2",
"110 (Std)": "En Route",
"110 (Custom)": "Custom-1",
"111 (Std)": "Off Duty",
"111 (Custom)": "Custom-0",
}
)
const (
gridChars = "ABCDEFGHIJKLMNOPQRSTUVWX0123456789"
messageTypeStd = "Std"
messageTypeCustom = "Custom"
)
// Position contains GPS coordinates.
type Position struct {
Latitude float64 `json:"latitude"` // Degrees
Longitude float64 `json:"longitude"` // Degrees
Ambiguity int `json:"ambiguity,omitempty"`
Symbol Symbol `json:"symbol"`
Compressed bool `json:"compressed,omitempty"`
}
func (pos Position) String() string {
if pos.Ambiguity == 0 {
return fmt.Sprintf("{%f, %f}", pos.Latitude, pos.Longitude)
}
return fmt.Sprintf("{%f, %f}, ambiguity=%d", pos.Latitude, pos.Longitude, pos.Ambiguity)
}
func ParseUncompressedPosition(s string) (Position, string, error) {
// APRS PROTOCOL REFERENCE 1.0.1 Chapter 8, page 32 (42 in PDF)
pos := Position{}
if len(s) < 18 {
return pos, "", ErrInvalidPosition
}
b := []byte(s)
for _, p := range disambiguation {
if b[p] == ' ' {
pos.Ambiguity++
b[p] = '0'
}
}
s = string(b)
var (
err error
latDeg, latMin, latMinFrag uint64
lngDeg, lngMin, lngMinFrag uint64
latHemi, lngHemi byte
isSouth, isWest bool
)
if latDeg, err = strconv.ParseUint(s[0:2], 10, 8); err != nil {
return pos, "", err
}
if latMin, err = strconv.ParseUint(s[2:4], 10, 8); err != nil {
return pos, "", err
}
if latMinFrag, err = strconv.ParseUint(s[5:7], 10, 8); err != nil {
return pos, "", err
}
latHemi = s[7]
pos.Symbol[0] = s[8]
if lngDeg, err = strconv.ParseUint(s[9:12], 10, 8); err != nil {
return pos, "", err
}
if lngMin, err = strconv.ParseUint(s[12:14], 10, 8); err != nil {
return pos, "", err
}
if lngMinFrag, err = strconv.ParseUint(s[15:17], 10, 8); err != nil {
return pos, "", err
}
lngHemi = s[17]
pos.Symbol[1] = s[18]
if latHemi == 'S' || latHemi == 's' {
isSouth = true
} else if latHemi != 'N' && latHemi != 'n' {
return pos, "", ErrInvalidPosition
}
if lngHemi == 'W' || lngHemi == 'w' {
isWest = true
} else if lngHemi != 'E' && lngHemi != 'e' {
return pos, "", ErrInvalidPosition
}
if latDeg > 89 || lngDeg > 179 {
return pos, "", ErrInvalidPosition
}
pos.Latitude = float64(latDeg) + float64(latMin)/60.0 + float64(latMinFrag)/6000.0
pos.Longitude = float64(lngDeg) + float64(lngMin)/60.0 + float64(lngMinFrag)/6000.0
if isSouth {
pos.Latitude = 0.0 - pos.Latitude
}
if isWest {
pos.Longitude = 0.0 - pos.Longitude
}
if pos.Symbol[1] >= 'a' || pos.Symbol[1] <= 'k' {
pos.Symbol[1] -= 32
}
if len(s) > 19 {
return pos, s[19:], nil
}
return pos, "", nil
}
func ParseCompressedPosition(s string) (Position, string, error) {
// APRS PROTOCOL REFERENCE 1.0.1 Chapter 9, page 36 (46 in PDF)
pos := Position{}
if len(s) < 10 {
return pos, "", ErrInvalidPosition
}
// Base-91 check
for _, c := range s[1:9] {
if c < 0x21 || c > 0x7b {
return pos, "", ErrInvalidPosition
}
}
var err error
var lat, lng int
if lat, err = base91Decode(s[1:5]); err != nil {
return pos, "", err
}
if lng, err = base91Decode(s[5:9]); err != nil {
return pos, "", err
}
pos.Latitude = 90.0 - float64(lat)/380926.0
pos.Longitude = -180.0 + float64(lng)/190463.0
pos.Compressed = true
return pos, s[10:], nil
}
func ParseMicE(s, dest string) (Position, error) {
// APRS PROTOCOL REFERENCE 1.0.1 Chapter 10, page 42 in PDF
pos := Position{}
if len(s) < 9 || len(dest) != 6 {
return pos, ErrInvalidPosition
}
ns := miceCodes[rune(dest[3])][2]
we := miceCodes[rune(dest[5])][4]
latF := fmt.Sprintf("%s%s", miceCodes[rune(dest[0])][0], miceCodes[rune(dest[1])][0])
latF = strings.Trim(latF, ". ")
latD, err := strconv.ParseFloat(latF, 64)
if err != nil {
return pos, ErrInvalidPosition
}
lonF := fmt.Sprintf("%s%s.%s%s", miceCodes[rune(dest[2])][0], miceCodes[rune(dest[3])][0], miceCodes[rune(dest[4])][0], miceCodes[rune(dest[5])][0])
lonF = strings.Trim(lonF, ". ")
latM, err := strconv.ParseFloat(lonF, 64)
if err != nil {
return pos, ErrInvalidPosition
}
if latM != 0 {
latD += latM / 60
}
if strings.ToUpper(ns) == "S" {
latD = -latD
}
lonOff := miceCodes[rune(dest[4])][3]
lonD := float64(s[1]) - 28
if lonOff == "100" {
lonD += 100
}
if lonD >= 180 && lonD < 190 {
lonD -= 80
}
if lonD >= 190 && lonD < 200 {
lonD -= 190
}
lonM := float64(s[2]) - 28
if lonM >= 60 {
lonM -= 60
}
// adding hundreth of minute then add minute as deg fraction
lonH := float64(s[3]) - 28
if lonH != 0 {
lonM += lonH / 100
}
if lonM != 0 {
lonD += lonM / 60
}
if strings.ToUpper(we) == "W" {
lonD = -lonD
}
pos.Latitude = latD
pos.Longitude = lonD
return pos, nil
}
func ParsePositionGrid(s string) (Position, string, error) {
var o int
for o = 0; o < len(s); o++ {
if strings.IndexByte(gridChars, s[o]) < 0 {
break
}
}
pos := Position{}
if o == 2 || o == 4 || o == 6 || o == 8 {
p, err := maidenhead.ParseLocator(s[:o])
if err != nil {
return pos, "", err
}
pos.Latitude = p.Latitude
pos.Longitude = p.Longitude
}
var txt string
if o < len(s) {
txt = s[o+1:]
}
return pos, txt, nil
}
func ParsePosition(s string, compressed bool) (Position, string, error) {
if compressed {
return ParseCompressedPosition(s)
}
return ParseUncompressedPosition(s)
}
func ParsePositionBoth(s string) (Position, string, error) {
pos, txt, err := ParseUncompressedPosition(s)
if err != nil {
return ParseCompressedPosition(s)
}
return pos, txt, err
}