Initial import

This commit is contained in:
2026-02-14 15:59:31 +01:00
parent 05dcea3c2b
commit f1ecbfaf8d
19 changed files with 2675 additions and 0 deletions

481
protocol/aprs/packet.go Normal file
View File

@@ -0,0 +1,481 @@
package aprs
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
var (
// ErrInvalidPacket signals a corrupted/unknown APRS packet.
ErrInvalidPacket = errors.New("aprs: invalid packet")
// ErrInvalidPosition signals a corrupted APRS position report.
ErrInvalidPosition = errors.New("aprs: invalid position")
)
// Payload is the raw payload contained within an APRS packet.
type Payload string
// Type of payload.
func (p Payload) Type() DataType {
var t DataType
if len(p) > 0 {
t = DataType(p[0])
}
return t
}
func isDigit(b byte) bool {
return b >= '0' && b <= '9'
}
func (p Payload) Len() int { return len(p) }
// Velocity details.
type Velocity struct {
Course float64 // Degrees
Speed float64 // Knots
}
// Wind details.
type Wind struct {
Direction float64 // Degrees
Speed float64 // Knots
}
// PowerHeightGain details.
type PowerHeightGain struct {
PowerCode byte
HeightCode byte
GainCode byte
DirectivityCode byte
}
// Power level (in Watts).
func (p PowerHeightGain) Power() int {
w := int(p.PowerCode - '0')
if w <= 0 {
return 0
}
return w * w
}
// Height above ground (in meters).
func (p PowerHeightGain) Height() float64 {
h := float64(p.HeightCode - '0')
if h <= 0 {
return 10
}
return math.Pow(2, h) * 10
}
// Gain level (in dBs).
func (p PowerHeightGain) Gain() int {
d := int(p.GainCode - '0')
if d <= 0 {
return 0
}
return d
}
// Directivity angle.
func (p PowerHeightGain) Directivity() float64 {
d := int(p.DirectivityCode - '0')
if d <= 0 {
return 0
}
return float64(d%8) * 45.0
}
// OmniDFStrength contains the omni-directional direction finding signal strength (for fox hunting).
type OmniDFStrength struct {
StrengthCode byte
HeightCode byte
GainCode byte
DirectivityCode byte
}
// Strength of the signal.
func (o OmniDFStrength) Strength() int {
w := int(o.StrengthCode - '0')
if w <= 0 {
return 0
}
return w * w
}
// Height above ground (in meters).
func (o OmniDFStrength) Height() float64 {
h := float64(o.HeightCode - '0')
if h <= 0 {
return 10
}
return math.Pow(2, h) * 10
}
// Gain level (in dBs).
func (o OmniDFStrength) Gain() int {
d := int(o.GainCode - '0')
if d <= 0 {
return 0
}
return d
}
// Directivity angle.
func (o OmniDFStrength) Directivity() float64 {
d := int(o.DirectivityCode - '0')
if d <= 0 {
return 0
}
return float64(d%8) * 45.0
}
// Packet contains an APRS packet.
type Packet struct {
// Raw packet (as captured from the air or APRS-IS).
Raw string `json:"raw"`
// Src is the source address.
Src Address `json:"src"`
// Dst is the destination address.
Dst Address `json:"dst"`
// Path contains the digipeater path.
Path Path `json:"path,omitempty"`
// Payload is the raw payload.
Payload Payload `json:"payload"`
// Position encoded in the payload.
Position *Position `json:"position,omitempty"`
// Time encoded in the payload.
Time *time.Time `json:"time,omitempty"`
// Altitude encoded in the payload (in feet).
Altitude float64 `json:"altitude,omitempty"`
// Velocity encoded in the payload.
Velocity *Velocity `json:"velocity,omitempty"`
// Wind details encoded in the payload.
Wind *Wind `json:"wind,omitempty"`
// PHG are the power, height and gain details encoded in the payload.
PHG *PowerHeightGain `json:"phg,omitempty"`
// DFS are the direction finder strength details encoded in the payload.
DFS *OmniDFStrength `json:"dfs,omitempty"`
// Range encoded in the payload (in miles).
Range float64 `json:"range,omitempty"`
// Symbol encoded in the payload.
Symbol Symbol `json:"symbol"`
// Comment encoded in the payload.
Comment string `json:"comment,omitempty"`
// Unparsed data.
data string
}
// ParsePacket parses an APRS packet as captured from AX.25 or APRS-IS.
func ParsePacket(raw string) (Packet, error) {
p := Packet{Raw: raw}
var i int
if i = strings.Index(raw, ":"); i < 0 {
return p, ErrInvalidPacket
}
p.Payload = Payload(raw[i+1:])
// Parse src, dst and path
var err error
var a = raw[:i]
if i = strings.Index(a, ">"); i < 0 {
return p, ErrInvalidPacket
}
if p.Src, err = ParseAddress(a[:i]); err != nil {
return p, err
}
var r = strings.Split(a[i+1:], ",")
if p.Dst, err = ParseAddress(r[0]); err != nil {
return p, err
}
if p.Path, err = ParsePath(strings.Join(r[1:], ",")); err != nil {
return p, err
}
// Post processing of payload
err = p.parse()
return p, err
}
func (p *Packet) parse() error {
s := string(p.Payload)
//log.Printf("parse %q [%c]\n", s, p.Payload.Type())
switch p.Payload.Type() {
case '!': // Lat/Long Position Report Format — without Timestamp
var o = strings.IndexByte(s, '!')
pos, txt, err := ParsePosition(s[o+1:], !isDigit(s[o+1]))
if err != nil {
return err
}
p.Position = &pos
p.data = txt
if len(s) >= 20 {
p.Symbol[0] = s[9]
p.Symbol[1] = s[19]
}
case '=':
compressed := IsValidCompressedSymTable(s[1])
pos, txt, err := ParsePosition(s[1:], compressed)
if err != nil {
return err
}
p.Position = &pos
p.data = txt
if compressed {
p.Symbol[0] = s[1]
p.Symbol[1] = s[10]
} else {
p.Symbol[0] = s[9]
p.Symbol[1] = s[19]
}
case '/', '@': // Lat/Long Position Report Format — with Timestamp
if len(s) < 8 {
return ErrInvalidPosition
}
var compressed bool
if s[7] == 'h' || s[7] == 'z' || s[7] == '/' {
if ts, err := ParseTime(s[1:]); err == nil {
p.Time = &ts
}
compressed = IsValidCompressedSymTable(s[8])
pos, txt, err := ParsePosition(s[8:], compressed)
if err != nil {
return err
}
p.Position = &pos
p.data = txt
} else if s[7] >= '0' && s[7] <= '9' {
ts, err := ParseTime(s[1:])
if err != nil {
return err
}
p.Time = &ts
compressed = IsValidCompressedSymTable(s[10])
pos, txt, err := ParsePosition(s[10:], compressed)
if err != nil {
return err
}
p.Position = &pos
p.data = txt
}
if compressed {
p.Symbol[0] = s[8]
p.Symbol[1] = s[17]
} else {
p.Symbol[0] = s[16]
p.Symbol[1] = s[26]
}
case ';':
pos, txt, err := ParsePosition(s[18:], !isDigit(s[18]))
if err != nil {
return err
}
p.Position = &pos
p.data = txt
case '[':
pos, txt, err := ParsePositionGrid(s[1:])
if err != nil {
return err
}
p.Position = &pos
p.data = txt
case '`', '\'':
pos, err := ParseMicE(s, p.Dst.Call)
if err != nil {
return err
}
p.Position = &pos
p.parseMicEData()
return nil // there is no additional data to parse
default:
pos, txt, err := ParsePositionBoth(s)
if err != nil {
if err != ErrInvalidPosition {
return err
}
p.Comment = s[1:]
} else {
p.Position = &pos
p.Comment = txt
}
}
if p.Position != nil {
if p.Position.Compressed {
return p.parseCompressedData()
}
return p.parseData()
}
return nil
}
func (p *Packet) parseMicEData() error {
// APRS PROTOCOL REFERENCE 1.0.1 Chapter 10, page 42 in PDF
s := string(p.Payload)
// Mic-E Message Type
var mt []string
var t string
for i := 0; i < 3; i++ {
mc := miceCodes[rune(p.Dst.Call[i])][1]
if strings.HasSuffix(mc, "(Custom)") {
t = messageTypeCustom
} else if strings.HasSuffix(mc, "(Std)") {
t = messageTypeStd
}
mt = append(mt, string(mc[0]))
}
switch t {
case messageTypeStd:
mt = append(mt, " (Std)")
case messageTypeCustom:
mt = append(mt, " (Custom)")
}
p.Comment = miceMsgTypes[strings.Join(mt, "")]
// Speed and Course.
speed := float64(int(s[4])-28) * 10
dc := float64(int(s[5])-28) / 10
unit := float64(int(dc))
speed += unit
course := dc - unit
course += float64(int(s[6]) - 28)
if speed >= 800 {
speed -= 800
}
speed = 1.852 * speed // convert speed from knots to km/h
if course >= 400 {
course -= 400
}
// Symbol
p.Symbol[0] = s[7]
p.Symbol[1] = s[8]
p.Comment += fmt.Sprintf(" (%.fkm/h, %.f°)", speed, course)
// Check whether there's additional Telemetry or Status Text data.
if len(s) == 9 {
return nil
}
if s[9] == ',' || s[9] == '\x1d' {
// TODO: Parse telemetry data.
return nil
}
// Parse MicE Status Text data.
// TODO: Parse additional data in the Status Text data:
// - Actual (custom) text message
// - Maidenhead locator
// - Altitude
return nil
}
func (p *Packet) parseCompressedData() error {
// Parse csT bytes
if len(p.data) >= 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 |
cb := p.data[0] - 33
sb := p.data[1] - 33
Tb := p.data[2] - 33
if p.data[0] != ' ' && ((Tb>>3)&3) == 2 {
// CGA sentence, NMEA Source = 0b10
d, err := base91Decode(p.data[0:2])
if err != nil {
return err
}
p.Altitude = math.Pow(1.002, float64(d))
p.Comment = p.data[3:]
} else if cb <= 89 { // !..z
// Course/Speed
p.Velocity.Course = float64(cb) * 4.0
p.Velocity.Speed = math.Pow(1.08, float64(sb)) - 1.0
} else if cb == 90 { // {
// Pre-Calculated Radio Range
p.Range = 2 * math.Pow(1.08, float64(sb))
}
}
return nil
}
func (p *Packet) parseData() error {
switch {
case len(p.data) >= 1 && p.data[0] == ' ':
p.Comment = p.data[1:]
case len(p.data) >= 7 && strings.HasPrefix(p.data, "PHG"):
p.PHG.PowerCode = p.data[3]
p.PHG.HeightCode = p.data[4]
p.PHG.GainCode = p.data[5]
p.PHG.DirectivityCode = p.data[6]
p.Range = math.Sqrt(2 * p.PHG.Height() * math.Sqrt((float64(p.PHG.Power())/10)*(float64(p.PHG.Gain())/2)))
p.Comment = p.data[7:]
case len(p.data) >= 7 && strings.HasPrefix(p.data, "RNG"):
var err error
p.Range, err = strconv.ParseFloat(p.data[3:7], 64)
if err != nil {
return err
}
p.Comment = p.data[7:]
case len(p.data) >= 7 && strings.HasPrefix(p.data, "DFS"):
p.DFS.StrengthCode = p.data[3]
p.DFS.HeightCode = p.data[4]
p.DFS.GainCode = p.data[5]
p.DFS.DirectivityCode = p.data[6]
p.Comment = p.data[7:]
}
return nil
}
func (p Payload) Time() (time.Time, error) {
switch p.Type() {
case '/', '@':
return ParseTime(string(p)[1:])
default:
return time.Time{}, nil
}
}