protocol/aprs: refactored
Some checks failed
Run tests / test (1.25) (push) Failing after 1m37s
Run tests / test (stable) (push) Failing after 1m37s

This commit is contained in:
2026-03-02 22:28:17 +01:00
parent 452f521866
commit 63040a44b3
24 changed files with 3506 additions and 1533 deletions

View File

@@ -2,312 +2,498 @@ package aprs
import (
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"git.maze.io/go/ham/util/maidenhead"
"time"
)
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"`
HasMessaging bool `json:"has_messaging"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude float64 `json:"altitude,omitempty"` // Altitude (in meters)
Range float64 `json:"range,omitempty"` // Radio range (in meters)
IsCompressed bool `json:"is_compressed"`
CompressedGPSFix uint8 `json:"-"`
CompressedNMEASource uint8 `json:"-"`
CompressedOrigin uint8 `json:"-"`
Time time.Time `json:"time"`
Comment string `json:"comment"`
Symbol string `json:"symbol"`
Velocity *Velocity `json:"velocity,omitempty"` // Velocity encoded in the payload.
Wind *Wind `json:"wind,omitempty"` // Wind direction and speed.
PHG *PowerHeightGain `json:"phg,omitempty"`
DFS *OmniDFStrength `json:"dfs,omitempty"`
Weather *Weather `json:"weather,omitempty"`
Telemetry *Telemetry `json:"telemetry,omitempty"` // Telemetry data
}
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 (pos *Position) String() string {
return "position"
}
func ParseUncompressedPosition(s string) (Position, string, error) {
// APRS PROTOCOL REFERENCE 1.0.1 Chapter 8, page 32 (42 in PDF)
// Velocity details.
type Velocity struct {
Course int // degrees
Speed float64 // meters per second
}
pos := Position{}
// Wind details.
type Wind struct {
Direction float64 // degrees
Speed float64 // meters per second
}
if len(s) < 18 {
return pos, "", ErrInvalidPosition
// PowerHeightGain details.
type PowerHeightGain struct {
PowerCode byte
HeightCode byte
GainCode byte
DirectivityCode byte
}
// OmniDFStrength contains the omni-directional direction finding signal strength (for fox hunting).
type OmniDFStrength struct {
StrengthCode byte
HeightCode byte
GainCode byte
DirectivityCode byte
}
type positionDecoder struct{}
func (d positionDecoder) CanDecode(frame *Frame) bool {
switch frame.Raw.Type() {
case '!', '=', '/', '@': // compressed/uncompressed with/without messaging
return true
default:
return false
}
}
b := []byte(s)
for _, p := range disambiguation {
if b[p] == ' ' {
pos.Ambiguity++
b[p] = '0'
}
}
s = string(b)
func (d positionDecoder) Decode(frame *Frame) (data Data, err error) {
var (
err error
latDeg, latMin, latMinFrag uint64
lngDeg, lngMin, lngMinFrag uint64
latHemi, lngHemi byte
isSouth, isWest bool
raw = frame.Raw
kind = raw.Type()
hasTimestamp = kind == '@' || kind == '/'
hasMessaging = kind == '@' || kind == '='
content = string(raw[1:])
pos = &Position{
HasMessaging: hasMessaging,
}
)
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
if hasTimestamp {
if len(content) < 7 {
return nil, io.ErrUnexpectedEOF
}
if pos.Time, _, err = parseTimestamp(content[:7]); err != nil {
return
}
content = content[7:]
}
var err error
var lat, lng int
if lat, err = base91Decode(s[1:5]); err != nil {
return pos, "", err
if err = pos.parsePositionAndComment(content); err != nil {
return
}
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 {
func (pos *Position) parsePositionAndComment(s string) (err error) {
var comment string
//log.Printf("parse position and comment %q: %t", s, isDigit(s[0]))
if isDigit(s[0]) {
// probably not compressed
if comment, err = pos.parseNotCompressedPosition(s); err != nil {
return
}
pos.IsCompressed = false
} else {
// probably compressed
if comment, err = pos.parseCompressedPosition(s); err != nil {
return
}
pos.IsCompressed = true
}
if len(comment) > 0 {
if err = pos.parseAltitudeWeatherAndExtension(comment); err != nil {
return
}
}
return
}
func (pos *Position) parseCompressedPosition(raw string) (comment string, err error) {
if len(raw) < 10 {
return "", fmt.Errorf("aprs: invalid compressed position string of length %d", len(raw))
}
pos.Symbol = string([]byte{raw[0], raw[9]})
var lat, lng int
if lat, err = base91Decode(raw[1:5]); err != nil {
return
}
if lng, err = base91Decode(raw[5:9]); err != nil {
return
}
pos.Latitude = 90.0 - float64(lat)/380926.0
pos.Longitude = -180.0 + float64(lng)/190463.0
pos.IsCompressed = true
comment = raw[13:]
if len(comment) >= 3 {
// Compression Type (T) Byte Format
//
// Bit: 7 | 6 | 5 | 4 3 | 2 1 0 |
// -------+--------+---------+-------------+------------------+
// Unused | Unused | GPS Fix | NMEA Source | Origin |
// -------+--------+---------+-------------+------------------+
// Val: 0 | 0 | 0 = old | 00 = other | 000 = Compressed |
// | | 1 = cur | 01 = GLL | 001 = TNC BTex |
// | | | 10 = CGA | 010 = Software |
// | | | 11 = RMC | 011 = [tbd] |
// | | | | 100 = KPC3 |
// | | | | 101 = Pico |
// | | | | 110 = Other |
// | | | | 111 = Digipeater |
var (
c = comment[0] - 33
s = comment[1] - 33
T = comment[2] - 33
)
if raw[10] == ' ' {
// Don't do any further processing
} else if ((T >> 3) & 3) == 2 {
// CGA sentence, NMEA Source = 0b10
var altitudeFeet int
if altitudeFeet, err = base91Decode(comment[:2]); err != nil {
return
}
pos.Altitude = feetToMeters(float64(altitudeFeet))
pos.CompressedGPSFix = (T >> 5) & 0x01
pos.CompressedNMEASource = (T >> 3) & 0x03
pos.CompressedOrigin = T & 0x07
comment = comment[3:]
} else if comment[0] >= '!' && comment[0] <= 'z' { // !..z
// Course/speed
pos.Velocity = &Velocity{
Course: int(c) * 4,
Speed: knotsToMetersPerSecond(math.Pow(1.08, float64(s))),
}
pos.CompressedGPSFix = (T >> 5) & 0x01
pos.CompressedNMEASource = (T >> 3) & 0x03
pos.CompressedOrigin = T & 0x07
comment = comment[3:]
} else if comment[0] == '{' { // {
// Precalculated range
pos.Range = milesToMeters(2 * math.Pow(1.08, float64(s)))
comment = comment[3:]
}
}
return
}
func (pos *Position) parseNotCompressedPosition(s string) (comment string, err error) {
//log.Printf("parse not compressed: %q", s)
if len(s) < 19 {
return "", fmt.Errorf("aprs: invalid not compressed position string of length %d", len(s))
}
pos.Symbol = string([]byte{s[8], s[18]})
if pos.Latitude, err = parseDMLatitude(s[:8]); err != nil {
return
}
if pos.Longitude, err = parseDMLongitude(s[9:18]); err != nil {
return
}
return s[19:], nil
}
var (
matchVelocity = regexp.MustCompile(`^[. 0-3][. 0-9]{2,2}/[. 0-9]{3}`)
matchPHG = regexp.MustCompile(`^PHG[0-9]{3,3}[0-8]`)
matchDFS = regexp.MustCompile(`^DFS[0-9]{3,3}[0-8]`)
matchRNG = regexp.MustCompile(`^RNG[0-9]{4}`)
matchAreaObject = regexp.MustCompile(``)
)
func (pos *Position) parseAltitudeWeatherAndExtension(s string) (err error) {
//log.Printf("parse altitude, weather and extensions %q", s)
var comment string
// Parse extensions
switch {
case matchVelocity.MatchString(s):
var course, speed int
if course, err = strconv.Atoi(fillZeros(s[:3])); err != nil {
return fmt.Errorf("invalid course: %v", err)
}
if speed, err = strconv.Atoi(fillZeros(s[4:7])); err != nil {
return fmt.Errorf("invalid speed: %v", err)
}
pos.Velocity = &Velocity{
Course: int(course),
Speed: knotsToMetersPerSecond(float64(speed)),
}
comment = s[7:]
if len(comment) > 2 && comment[0] == '/' && isDigit(comment[1]) && isDigit(comment[2]) {
var dir int
if dir, err = strconv.Atoi(fillZeros(comment[1:4])); err != nil {
return fmt.Errorf("invalid wind direction: %v", err)
}
if speed, err = strconv.Atoi(fillZeros(comment[5:8])); err != nil {
return fmt.Errorf("invalid wind speed: %v", err)
}
pos.Wind = &Wind{
Direction: float64(dir),
Speed: knotsToMetersPerSecond(float64(speed)),
}
comment = comment[8:]
}
case matchPHG.MatchString(s):
comment = s[7:]
case matchDFS.MatchString(s):
comment = s[7:]
case matchRNG.MatchString(s):
comment = s[7:]
default:
comment = s
}
//log.Printf("after extensions: %q", comment)
if pos.Altitude, comment, err = parseAltitude(comment); err != nil {
return
}
//log.Printf("after altitude, before weather: %q", comment)
if pos.Weather, comment, err = parseWeather(comment); err != nil {
return
}
//log.Printf("after weather, before telemetry: %q", comment)
if pos.Telemetry, comment, err = parseBase91Telemetry(comment); err != nil {
return
}
pos.Comment = comment
return
}
func parseAltitude(s string) (altitude float64, comment string, err error) {
const altitudeMarker = "/A="
if i := strings.Index(s, altitudeMarker); i >= 0 {
//log.Printf("parse altitude marker: %q", s[i+3:])
var feet int
if feet, err = strconv.Atoi(strings.TrimSpace(s[i+3 : i+3+6])); err != nil {
return 0, "", fmt.Errorf("aprs: invalid altitude: %v", err)
}
return feetToMeters(float64(feet)), s[:i] + s[i+3+6:], nil
}
return 0, s, nil
}
var weatherSymbolSize = map[byte]int{
'g': 3, // peak wind speed in the past 5 minutes, in mph
't': 3, // temperature in degrees Fahrenheit
'r': 3, // rainfall in hundredths of an inch
'p': 3, // rainfall in hundredths of an inch
'P': 3, // rainfall in hundredths of an inch
'h': 2, // relative humidity in %
'b': 5, // barometric pressure in tenths of millibars
'l': 3, // luminosity (in Watts per square meter) 1000 and above
'L': 3, // luminosity (in Watts per square meter) 999 and below
's': 3, // snowfall in the last 24 hours in inches
'#': 3, // raw rain counter
}
// Weather report.
type Weather struct {
WindGust float64 `json:"windGust"` // wind gust in m/s
Temperature float64 `json:"temperature"` // temperature (in degrees C)
Rain1h float64 `json:"rain1h"` // rain in the last hour (in mm)
Rain24h float64 `json:"rain24h"` // rain in the last day (in mm)
RainSinceMidnight float64 `json:"rainSinceMidnight"` // rain since midnight (in mm)
RainRaw int `json:"rainRaw"` // rain raw counter
Humidity float64 `json:"humidity"` // relative humidity (in %)
Pressure float64 `json:"pressure"` // pressure (in mBar)
Luminosity float64 `json:"luminosity"` // luminosity (in W/m^2)
Snowfall float64 `json:"snowfall"` // snowfall (in cm/day)
}
func parseWeather(s string) (weather *Weather, comment string, err error) {
comment = s
for len(comment) > 0 {
if size, ok := weatherSymbolSize[comment[0]]; ok {
if len(comment[1:]) < size {
return nil, "", fmt.Errorf("aprs: not enough characters to encode weather symbol %c (%d < %d)", comment[0], len(comment[1:]), size)
}
var value float64
if value, err = strconv.ParseFloat(comment[1:size+1], 64); err != nil {
// Something else that started with a weather symbol, perhaps a comment, stop parsing
err = nil
break
}
if weather == nil {
weather = new(Weather)
}
switch comment[0] {
case 'g':
weather.WindGust = value * 0.4470
case 't':
weather.Temperature = fahrenheitToCelcius(value)
case 'r':
weather.Rain1h = value * 0.254
case 'p':
weather.Rain24h = value * 0.254
case 'P':
weather.RainSinceMidnight = value * 0.254
case 'h':
weather.Humidity = value
case 'b':
weather.Pressure = value / 10
case 'l':
weather.Luminosity = value + 1000
case 'L':
weather.Luminosity = value
case 's':
weather.Snowfall = value * 2.54
case '#':
weather.RainRaw = int(value)
}
comment = comment[1+size:]
} else {
break
}
}
pos := Position{}
if o == 2 || o == 4 || o == 6 || o == 8 {
p, err := maidenhead.ParseLocator(s[:o])
if err != nil {
return pos, "", err
return
}
type Telemetry struct {
ID int
Analog []int
Digital []bool
}
var matchTelemetry = regexp.MustCompile(`\|([!-{]{4,14})\|`)
func parseBase91Telemetry(s string) (telemetry *Telemetry, comment string, err error) {
var i int
if i = strings.IndexByte(s, '|'); i == -1 {
return nil, s, nil
}
var sequence string
comment, sequence = s[:i], s[i+1:]
if i = strings.IndexByte(sequence, '|'); i < 1 {
// no closing | found, return as comment
return nil, s, nil
}
if sequence, comment = sequence[:i], comment+sequence[i+1:]; len(sequence)%2 != 0 {
// uneven number of sequence elements,
return nil, s, nil
}
telemetry = new(Telemetry)
if telemetry.ID, err = base91Decode(sequence[:2]); err != nil {
// it wasn't base-91 encoded telemetry, return data as comment
return nil, s, nil
}
var values []int
for i = 2; i < len(sequence); i += 2 {
var value int
if value, err = base91Decode(sequence[i : i+2]); err != nil {
// it wasn't base-91 encoded telemetry, return data as comment
return nil, s, nil
}
pos.Latitude = p.Latitude
pos.Longitude = p.Longitude
values = append(values, value)
}
var txt string
if o < len(s) {
txt = s[o+1:]
if len(values) > 5 {
for i = 0; i < 8; i++ {
telemetry.Digital = append(telemetry.Digital, (values[5]&1) == 1)
values[5] >>= 1
}
values = values[:5]
}
return pos, txt, nil
telemetry.Analog = values
return
}
func ParsePosition(s string, compressed bool) (Position, string, error) {
if compressed {
return ParseCompressedPosition(s)
func parseDMLatitude(s string) (v float64, err error) {
if len(s) != 8 || s[4] != '.' || !(s[7] == 'N' || s[7] == 'S') {
return 0, fmt.Errorf("aprs: invalid latitude %q", s)
}
return ParseUncompressedPosition(s)
s = strings.Replace(s, " ", "0", -1) // position ambiguity
var (
degs, mins, minFrags int
south = s[7] == 'S'
)
if degs, err = strconv.Atoi(s[:2]); err != nil {
return
}
if mins, err = strconv.Atoi(s[2:4]); err != nil {
return
}
if minFrags, err = strconv.Atoi(s[5:7]); err != nil {
return
}
v = float64(degs) + float64(mins)/60 + float64(minFrags)/6000
if south {
return -v, nil
}
return v, nil
}
func ParsePositionBoth(s string) (Position, string, error) {
pos, txt, err := ParseUncompressedPosition(s)
if err != nil {
return ParseCompressedPosition(s)
func parseDMLongitude(s string) (v float64, err error) {
if len(s) != 9 || s[5] != '.' || !(s[8] == 'W' || s[8] == 'E') {
return 0, fmt.Errorf("aprs: invalid longitude %q", s)
}
return pos, txt, err
s = strings.Replace(s, " ", "0", -1) // position ambiguity
var (
degs, mins, minFrags int
east = s[8] == 'E'
)
if degs, err = strconv.Atoi(s[:3]); err != nil {
return
}
if mins, err = strconv.Atoi(s[3:5]); err != nil {
return
}
if minFrags, err = strconv.Atoi(s[6:8]); err != nil {
return
}
v = float64(degs) + float64(mins)/60 + float64(minFrags)/6000
if east {
return v, nil
}
return -v, nil
}