Files
ham/protocol/aprs/position.go
maze 8f8a97300f
Some checks failed
Run tests / test (1.25) (push) Failing after 1m36s
Run tests / test (stable) (push) Failing after 1m36s
protocol/aprs: extract comment to frame
2026-03-02 22:37:39 +01:00

506 lines
14 KiB
Go

package aprs
import (
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
)
type Position struct {
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 {
return "position"
}
// Velocity details.
type Velocity struct {
Course int // degrees
Speed float64 // meters per second
}
// Wind details.
type Wind struct {
Direction float64 // degrees
Speed float64 // meters per second
}
// 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
}
}
func (d positionDecoder) Decode(frame *Frame) (data Data, err error) {
var (
raw = frame.Raw
kind = raw.Type()
hasTimestamp = kind == '@' || kind == '/'
hasMessaging = kind == '@' || kind == '='
content = string(raw[1:])
pos = &Position{
HasMessaging: hasMessaging,
}
)
if hasTimestamp {
if len(content) < 7 {
return nil, io.ErrUnexpectedEOF
}
if pos.Time, _, err = parseTimestamp(content[:7]); err != nil {
return
}
content = content[7:]
}
if err = pos.parsePositionAndComment(content); err != nil {
return
}
frame.Latitude = pos.Latitude
frame.Longitude = pos.Longitude
frame.Altitude = pos.Altitude
frame.Symbol = pos.Symbol
frame.Comment = pos.Comment
return pos, nil
}
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
}
}
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
}
values = append(values, value)
}
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]
}
telemetry.Analog = values
return
}
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)
}
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 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)
}
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
}