diff --git a/protocol/aprs/address.go b/protocol/aprs/address.go index f595505..99a288e 100644 --- a/protocol/aprs/address.go +++ b/protocol/aprs/address.go @@ -1,126 +1,82 @@ package aprs -import ( - "encoding/json" - "errors" - "fmt" - "strings" -) - -var ( - ErrAddressInvalid = errors.New(`aprs: invalid address`) -) +import "strings" type Address struct { Call string `json:"call"` SSID string `json:"ssid,omitempty"` - IsRepeated bool `json:"is_repeated,omitempty"` + IsRepeated bool `json:"is_repeated"` +} + +func ParseAddress(s string) Address { + r := strings.HasSuffix(s, "*") + if r { + s = strings.TrimSuffix(s, "*") + } + i := strings.IndexByte(s, '-') + if i < 1 { + return Address{ + Call: s, + IsRepeated: r, + } + } + return Address{ + Call: s[:i], + SSID: s[i+1:], + IsRepeated: r, + } } func (a Address) EqualTo(b Address) bool { - return a.Call == b.Call && a.SSID == b.SSID + return strings.EqualFold(a.Call, b.Call) && a.SSID == b.SSID +} + +func (a Address) IsQConstruct() bool { + return len(a.Call) == 3 && len(a.SSID) == 0 && a.Call[0] == 'q' +} + +func (a Address) Passcode() int16 { + v := int16(0x73e2) + for i, l := 0, len(a.Call); i < l; i = i + 2 { + v ^= int16(a.Call[i]) << 8 + if i+1 < len(a.Call) { + v ^= int16(a.Call[i+1]) + } + } + return v & 0x7fff } func (a Address) String() string { - var r = "" - + var b strings.Builder + b.WriteString(a.Call) + if a.SSID != "" { + b.WriteByte('-') + b.WriteString(a.SSID) + } if a.IsRepeated { - r = "*" + b.WriteByte('*') } - if a.SSID == "" { - return a.Call + r - } - return fmt.Sprintf("%s-%s%s", a.Call, a.SSID, r) -} - -func (a Address) Secret() int16 { - var h = int16(0x73e2) - var c = a.Call - - if len(c)%2 > 0 { - c += "\x00" - } - for i := 0; i < len(c); i += 2 { - h ^= int16(c[i]) << 8 - h ^= int16(c[i+1]) - } - return h & 0x7fff -} - -func (a Address) MarshalJSON() ([]byte, error) { - return json.Marshal(a.String()) -} - -func (a *Address) UnmarshalJSON(b []byte) error { - var s string - if err := json.Unmarshal(b, &s); err != nil { - return err - } - p, err := ParseAddress(s) - if err != nil { - return err - } - a.Call = p.Call - a.SSID = p.SSID - a.IsRepeated = p.IsRepeated - return nil -} - -func ParseAddress(s string) (Address, error) { - r := strings.HasSuffix(s, "*") - if r { - s = s[:len(s)-1] - } - p := strings.Split(s, "-") - if len(p) == 0 || len(p) > 2 { - return Address{}, ErrAddressInvalid - } - - a := Address{Call: p[0], IsRepeated: r} - if len(p) == 2 { - a.SSID = p[1] - } - - return a, nil -} - -func MustParseAddress(s string) Address { - a, err := ParseAddress(s) - if err != nil { - panic(err) - } - return a -} - -func IsQConstruct(call string) bool { - return len(call) == 3 && call[0] == 'q' + return b.String() } type Path []Address -func (p Path) String() string { - var s = make([]string, len(p)) - for i, a := range p { - s[i] = a.String() +func (path Path) EqualTo(other Path) bool { + if len(path) != len(other) { + return false } - return strings.Join(s, ",") -} - -func ParsePath(p string) (Path, error) { - ss := strings.Split(p, ",") - - if len(ss) == 0 { - return nil, nil - } - - var err error - as := make(Path, len(ss)) - for i, s := range ss { - as[i], err = ParseAddress(s) - if err != nil { - return nil, err + for i, a := range path { + if !a.EqualTo(other[i]) { + return false } } - - return as, nil + return true +} + +func (path Path) String() string { + part := make([]string, len(path)) + for i, a := range path { + part[i] = a.String() + } + return strings.Join(part, ",") } diff --git a/protocol/aprs/base91.go b/protocol/aprs/base91.go index 779a654..67869be 100644 --- a/protocol/aprs/base91.go +++ b/protocol/aprs/base91.go @@ -2,12 +2,14 @@ package aprs import ( "fmt" + "slices" ) func base91Decode(s string) (n int, err error) { for i, l := 0, len(s); i < l; i++ { c := s[i] if c < 33 || c > 122 { + // panic(fmt.Sprintf("aprs: invalid base-91 encoding char %q (%d)", c, c)) return 0, fmt.Errorf("aprs: invalid base-91 encoding char %q (%d)", c, c) } @@ -17,14 +19,13 @@ func base91Decode(s string) (n int, err error) { return } -/* -func base91Encode(n int) string { - var s []string - for n > 0 { - c := n % 91 +func base91Encode(b []byte, n int) { + i := 0 + for n > 1 { + x := n % 91 n /= 91 - s = append([]string{string(byte(c) + 33)}, s...) + b[i] = byte(x) + 33 + i++ } - return strings.Join(s, "") + slices.Reverse(b[:i]) } -*/ diff --git a/protocol/aprs/data.go b/protocol/aprs/data.go new file mode 100644 index 0000000..7c04d63 --- /dev/null +++ b/protocol/aprs/data.go @@ -0,0 +1,119 @@ +package aprs + +import ( + "fmt" + "regexp" + "strconv" + "time" +) + +type decoder interface { + CanDecode(*Frame) bool + Decode(*Frame) (Data, error) +} + +var decoders []decoder + +func init() { + decoders = []decoder{ + positionDecoder{}, + micEDecoder{}, + messageDecoder{}, + queryDecoder{}, + } +} + +// Raw is the string encoded raw frame payload. +type Raw string + +func (p Raw) Type() Type { + var t Type + if len(p) > 0 { + t = Type(p[0]) + } + return t +} + +// Data represents a decoded payload data. +type Data interface { + String() string +} + +// Type of payload. +type Type byte + +var typeName = map[Type]string{ + 0x1c: "Current Mic-E Data (Rev 0 beta)", + 0x1d: "Old Mic-E Data (Rev 0 beta)", + '!': "Position without timestamp (no APRS messaging), or Ultimeter 2000 WX Station", + '#': "Peet Bros U-II Weather Station", + '$': "Raw GPS data or Ultimeter 2000", + '%': "Agrelo DFJr / MicroFinder", + '"': "Old Mic-E Data (but Current data for TM-D700)", + ')': "Item", + '*': "Peet Bros U-II Weather Station", + ',': "Invalid data or test data", + '/': "Position with timestamp (no APRS messaging)", + ':': "Message", + ';': "Object", + '<': "Station Capabilities", + '=': "Position without timestamp (with APRS messaging)", + '>': "Status", + '?': "Query", + '@': "Position with timestamp (with APRS messaging)", + 'T': "Telemetry data", + '[': "Maidenhead grid locator beacon (obsolete)", + '_': "Weather Report (without position)", + '`': "Current Mic-E Data (not used in TM-D700)", + '{': "User-Defined APRS packet format", + '}': "Third-party traffic", +} + +func (t Type) String() string { + if s, ok := typeName[t]; ok { + return s + } + return fmt.Sprintf("unknown %02x", byte(t)) +} + +func (t Type) IsMessage() bool { + return t == ':' +} + +func (t Type) IsThirdParty() bool { + return t == '}' +} + +var matchTimestamp = regexp.MustCompile(`^[0-9]{6}[zh/]`) + +func hasTimestamp(s string) bool { + return matchTimestamp.MatchString(s) +} + +func parseTimestamp(s string) (t time.Time, comment string, err error) { + // log.Printf("parse timestamp %q", s) + var hh, mm, ss int + if hh, err = strconv.Atoi(s[0:2]); err != nil { + return + } + if mm, err = strconv.Atoi(s[2:4]); err != nil { + return + } + if ss, err = strconv.Atoi(s[4:6]); err != nil { + return + } + + now := time.Now() + switch s[6] { + case 'z': // DDHHMMz zulu time + now = now.UTC() + return time.Date(now.Year(), now.Month(), hh, mm, ss, 0, 0, time.UTC), s[7:], nil + case '/': // DDHHMM/ local time + return time.Date(now.Year(), now.Month(), hh, mm, ss, 0, 0, now.Location()), s[7:], nil + case 'h': // HHMMSSh zulu time + now = now.UTC() + return time.Date(now.Year(), now.Month(), now.Day(), hh, mm, ss, 0, time.UTC), s[7:], nil + default: + return time.Time{}, "", fmt.Errorf("aprs: invalid/unknown timestamp marker %q", s[6]) + } +} diff --git a/protocol/aprs/data_test.go b/protocol/aprs/data_test.go new file mode 100644 index 0000000..c70daea --- /dev/null +++ b/protocol/aprs/data_test.go @@ -0,0 +1,29 @@ +package aprs + +import ( + "reflect" + "testing" +) + +func testCompareData(t *testing.T, want, test Data) { + t.Helper() + + if want == nil && test != nil { + t.Errorf("expected no data, got %T", test) + return + } + if want != nil && test == nil { + t.Errorf("expected data %T, got nil", want) + return + } + + if reflect.TypeOf(want) != reflect.TypeOf(test) { + t.Errorf("expected data %T, got %T", want, test) + return + } + + switch want := want.(type) { + case *Position: + testComparePosition(t, want, test.(*Position)) + } +} diff --git a/protocol/aprs/datatype.go b/protocol/aprs/datatype.go deleted file mode 100644 index 05b4c1f..0000000 --- a/protocol/aprs/datatype.go +++ /dev/null @@ -1,41 +0,0 @@ -package aprs - -import "fmt" - -type DataType byte - -var ( - dataTypeName = map[DataType]string{ - 0x1c: "Current Mic-E Data (Rev 0 beta)", - 0x1d: "Old Mic-E Data (Rev 0 beta)", - '!': "Position without timestamp (no APRS messaging), or Ultimeter 2000 WX Station", - '#': "Peet Bros U-II Weather Station", - '$': "Raw GPS data or Ultimeter 2000", - '%': "Agrelo DFJr / MicroFinder", - '"': "Old Mic-E Data (but Current data for TM-D700)", - ')': "Item", - '*': "Peet Bros U-II Weather Station", - ',': "Invalid data or test data", - '/': "Position with timestamp (no APRS messaging)", - ':': "Message", - ';': "Object", - '<': "Station Capabilities", - '=': "Position without timestamp (with APRS messaging)", - '>': "Status", - '?': "Query", - '@': "Position with timestamp (with APRS messaging)", - 'T': "Telemetry data", - '[': "Maidenhead grid locator beacon (obsolete)", - '_': "Weather Report (without position)", - '`': "Current Mic-E Data (not used in TM-D700)", - '{': "User-Defined APRS packet format", - '}': "Third-party traffic", - } -) - -func (t DataType) String() string { - if s, ok := dataTypeName[t]; ok { - return s - } - return fmt.Sprintf("Unknown packet type %#02x", byte(t)) -} diff --git a/protocol/aprs/frame.go b/protocol/aprs/frame.go new file mode 100644 index 0000000..c00284a --- /dev/null +++ b/protocol/aprs/frame.go @@ -0,0 +1,58 @@ +package aprs + +import ( + "errors" + "strings" +) + +var ( + ErrPayloadMarker = errors.New("aprs: can't find payload marker") + ErrDestinationMarker = errors.New("aprs: can't find destination marker") + ErrPathLength = errors.New("aprs: invalid path length") +) + +// Frame represents a single APRS frame. +type Frame struct { + Source Address `json:"source"` + Destination Address `json:"destination"` + Path Path `json:"path"` + Raw Raw `json:"raw"` + Data Data `json:"data,omitempty"` +} + +func Parse(s string) (*Frame, error) { + i := strings.IndexByte(s, ':') + if i == -1 { + return nil, ErrPayloadMarker + } + + var ( + route = s[:i] + frame = &Frame{Raw: Raw(s[i+1:])} + ) + if i = strings.IndexByte(route, '>'); i == -1 { + return nil, ErrDestinationMarker + } + frame.Source, route = ParseAddress(route[:i]), route[i+1:] + + path := strings.Split(route, ",") + if len(path) == 0 || len(path) > 9 { + return nil, ErrPathLength + } + frame.Destination = ParseAddress(path[0]) + for i, l := 1, len(path); i < l; i++ { + addr := ParseAddress(path[i]) + frame.Path = append(frame.Path, addr) + } + + var err error + for _, d := range decoders { + if d.CanDecode(frame) { + if frame.Data, err = d.Decode(frame); err == nil { + break + } + } + } + + return frame, nil +} diff --git a/protocol/aprs/frame_test.go b/protocol/aprs/frame_test.go new file mode 100644 index 0000000..d41cdf0 --- /dev/null +++ b/protocol/aprs/frame_test.go @@ -0,0 +1,136 @@ +package aprs + +import ( + "bufio" + "io" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + Line string + Want *Frame + }{ + { + `PD0MZ-10>APLRG1,WIDE1-1,qAC:=L4:I#P),la !GLoRa APRS iGate / 433.775MHz / 125kHz / SF12 / CR5 Batt=4.25V`, + &Frame{ + Source: Address{Call: "PD0MZ", SSID: "10"}, + Destination: Address{Call: "APLRG1"}, + Path: Path{{Call: "WIDE1", SSID: "1"}, {Call: "qAC"}}, + Data: &Position{ + Latitude: 51.860004, + Longitude: 6.309997, + HasMessaging: true, + Symbol: "La", + Comment: "LoRa APRS iGate / 433.775MHz / 125kHz / SF12 / CR5 Batt=4.25V", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.Line, func(t *testing.T) { + f, err := Parse(test.Line) + if err != nil { + t.Fatal(err) + } + //if !reflect.DeepEqual(f, test.Want) { + // t.Errorf("expected %+#v, got %#+v", test.Want, f) + //} + testCompareFrame(t, test.Want, f) + }) + } +} + +func TestParseSamples(t *testing.T) { + f, err := os.Open(filepath.Join("testdata", "packets.txt")) + if err != nil { + t.Skip(err) + return + } + defer func() { _ = f.Close() }() + + r := bufio.NewReader(f) + for { + l, err := r.ReadString('\n') + if err != nil { + if err == io.EOF { + return + } + t.Fatal(err) + } + + l = testUnescapeHex(strings.TrimSpace(l[25:])) + if _, err = Parse(l); err != nil { + t.Errorf("%s: %v", l, err) + } + } +} + +// testUnescapeHex replaces occurrences of <0xHH> (case-insensitive) +// with the corresponding single byte. +// Invalid sequences are left unchanged. +func testUnescapeHex(s string) string { + if !strings.Contains(s, "<0x") && !strings.Contains(s, "<0X") { + return s + } + + var b strings.Builder + b.Grow(len(s)) // upper bound; result will not exceed input length + + for i := 0; i < len(s); { + // Minimal length for "<0xHH>" is 6 + if i+6 <= len(s) && + s[i] == '<' && + (s[i+1] == '0') && + (s[i+2] == 'x' || s[i+2] == 'X') && + testIsHex(s[i+3]) && + testIsHex(s[i+4]) && + s[i+5] == '>' { + + // Parse the hex byte + v, err := strconv.ParseUint(s[i+3:i+5], 16, 8) + if err == nil { + b.WriteByte(byte(v)) + i += 6 + continue + } + } + + // Default: copy one byte and continue + b.WriteByte(s[i]) + i++ + } + + return b.String() +} + +func testIsHex(c byte) bool { + return ('0' <= c && c <= '9') || + ('a' <= c && c <= 'f') || + ('A' <= c && c <= 'F') +} + +func testAlmostEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-5 +} + +func testCompareFrame(t *testing.T, want, test *Frame) { + t.Helper() + + if !test.Source.EqualTo(want.Source) { + t.Errorf("expected source %s, got %s", want.Source, test.Source) + } + if !test.Destination.EqualTo(want.Destination) { + t.Errorf("expected destination %s, got %s", want.Destination, test.Destination) + } + if !test.Path.EqualTo(want.Path) { + t.Errorf("expected path %q, got %q", want.Path, test.Path) + } + testCompareData(t, want.Data, test.Data) +} diff --git a/protocol/aprs/message.go b/protocol/aprs/message.go new file mode 100644 index 0000000..6e371e7 --- /dev/null +++ b/protocol/aprs/message.go @@ -0,0 +1,99 @@ +package aprs + +import ( + "fmt" + "strconv" + "strings" +) + +type Message struct { + ID int `json::"id"` + IsAcknowledge bool `json:"is_ack"` + IsRejection bool `json:"is_rejection"` + IsBulletin bool `json:"is_bulletin"` + Recipient string `json:"recipient"` + AnnoucementID byte `json:"announcement_id,omitempty"` + Group string `json:"group,omitempty"` + Severity string `json:"severity,omitempty"` + Text string `json:"text"` +} + +func (msg *Message) String() string { + return fmt.Sprintf("%s: %q", msg.Recipient, msg.Text) +} + +type messageDecoder struct{} + +func (messageDecoder) CanDecode(frame *Frame) bool { + var ( + maybeMessage = len(frame.Raw) >= 11 && frame.Raw[10] == ':' + maybeBulletin = len(frame.Raw) >= 10 && frame.Raw[9] == ':' + ) + return frame.Raw.Type() == ':' && (maybeMessage || maybeBulletin) +} + +func (messageDecoder) Decode(frame *Frame) (data Data, err error) { + var ( + msg = new(Message) + text string + size = len(text) + ) + if len(frame.Raw) >= 10 && frame.Raw[9] == ':' { + msg.Recipient, text = strings.TrimSpace(string(frame.Raw[1:9])), string(frame.Raw[9:]) + } else { + msg.Recipient, text = strings.TrimSpace(string(frame.Raw[1:10])), string(frame.Raw[10:]) + } + + switch { + case strings.HasPrefix(msg.Recipient, "BLN"): + // Bulletin + kind := msg.Recipient[3:] + if id, err := strconv.Atoi(kind); err == nil { + // General bulletin + msg.IsBulletin = true + msg.ID = id + } else if len(kind) >= 2 && isDigit(kind[0]) { + // Group Bulletin Format + msg.IsBulletin = true + msg.ID = int(kind[0] - '0') + msg.Group = kind[1:] + } else if len(kind) == 1 { + // Announcement + msg.IsBulletin = true + msg.AnnoucementID = kind[0] + } + + case strings.HasPrefix(msg.Recipient, "NWS-"): + // National Weather Service Bulletins + msg.IsBulletin = true + msg.Severity = msg.Recipient[4:] + + default: + if i := strings.LastIndexByte(text, '{'); i != -1 && i > (size-6) { + // Plain message ID: {XXXXX (where there are 1-5 X) + msg.ID, _ = strconv.Atoi(text[i+1:]) + text = text[:i] + } + + if i := strings.LastIndex(text, ":ack"); i != -1 && i > (size-9) { + // Message acknowledgement: :ackXXXXX (where there are 1-5 X) + msg.IsAcknowledge = true + msg.ID, _ = strconv.Atoi(text[i+4:]) + text = text[:i] + } + + if i := strings.LastIndex(text, ":rej"); i != -1 && i > (size-9) { + // Message acknowledgement: :rejXXXXX (where there are 1-5 X) + msg.IsRejection = true + msg.ID, _ = strconv.Atoi(text[i+4:]) + text = text[:i] + } + } + + if len(text) > 0 && text[0] == ':' { + text = text[1:] + } + + msg.Text = text + return msg, nil +} diff --git a/protocol/aprs/message_test.go b/protocol/aprs/message_test.go new file mode 100644 index 0000000..06a009b --- /dev/null +++ b/protocol/aprs/message_test.go @@ -0,0 +1,153 @@ +package aprs + +import ( + "testing" +) + +func TestParseMessage(t *testing.T) { + tests := []struct { + Name string + Raw Raw + Want *Message + }{ + { + "message, no ack expected", + ":WU2Z :Testing", + &Message{ + Recipient: "WU2Z", + Text: "Testing", + }, + }, + { + "message with sequence number, ack expected", + ":WU2Z :Testing{003", + &Message{ + ID: 3, + Recipient: "WU2Z", + Text: "Testing", + }, + }, + { + "an e-mail message", + ":EMAIL :msproul@ap.org Test email", + &Message{ + Recipient: "EMAIL", + Text: "msproul@ap.org Test email", + }, + }, + { + "message acknowledgement", + ":KB2ICI-14:ack003", + &Message{ + ID: 3, + IsAcknowledge: true, + Recipient: "KB2ICI-14", + }, + }, + { + "message rejection", + ":KB2ICI-14:rej003", + &Message{ + ID: 3, + IsRejection: true, + Recipient: "KB2ICI-14", + }, + }, + { + "bulletin", + ":BLN3 :Snow expected in Tampa RSN", + &Message{ + ID: 3, + IsBulletin: true, + Recipient: "BLN3", + Text: "Snow expected in Tampa RSN", + }, + }, + { + "annoucement", + ":BLNQ :Mt St Helen digi will be QRT this weekend", + &Message{ + IsBulletin: true, + Recipient: "BLNQ", + AnnoucementID: 'Q', + Text: "Mt St Helen digi will be QRT this weekend", + }, + }, + { + "group bulletin 4 to the WX group", + ":BLN4WX :Stand by your snowplows", + &Message{ + ID: 4, + IsBulletin: true, + Recipient: "BLN4WX", + Group: "WX", + Text: "Stand by your snowplows", + }, + }, + { + "national weather service alert", + ":NWS-WARN :092010z,THUNDER_STORM,AR_ASHLEY,{S9JbA", + &Message{ + IsBulletin: true, + Recipient: "NWS-WARN", + Severity: "WARN", + Text: "092010z,THUNDER_STORM,AR_ASHLEY,{S9JbA", + }, + }, + } + + var decoder messageDecoder + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + frame := &Frame{Raw: test.Raw} + if !decoder.CanDecode(frame) { + t.Fatalf("%T can't decode %q", decoder, test.Raw) + } + + v, err := decoder.Decode(frame) + if err != nil { + t.Fatal(err) + } + + testCompareMessage(t, test.Want, v) + }) + } +} + +func testCompareMessage(t *testing.T, want *Message, value any) { + t.Helper() + + test, ok := value.(*Message) + if !ok { + t.Fatalf("expected data to be a %T, got %T", want, value) + return + } + + if test.ID != want.ID { + t.Errorf("expected id %d, got %d", want.ID, test.ID) + } + if test.IsAcknowledge != want.IsAcknowledge { + t.Errorf("expected is acknowledge %t, got %t", want.IsAcknowledge, test.IsAcknowledge) + } + if test.IsRejection != want.IsRejection { + t.Errorf("expected is rejection %t, got %t", want.IsRejection, test.IsRejection) + } + if test.IsBulletin != want.IsBulletin { + t.Errorf("expected is bulletin %t, got %t", want.IsBulletin, test.IsBulletin) + } + if test.Recipient != want.Recipient { + t.Errorf("expected recipient %q, got %q", want.Recipient, test.Recipient) + } + if test.AnnoucementID != want.AnnoucementID { + t.Errorf("expected annoucement id %q, got %q", want.AnnoucementID, test.AnnoucementID) + } + if test.Group != want.Group { + t.Errorf("expected group %q, got %q", want.Group, test.Group) + } + if test.Severity != want.Severity { + t.Errorf("expected severity %q, got %q", want.Severity, test.Severity) + } + if test.Text != want.Text { + t.Errorf("expected text %q, got %q", want.Text, test.Text) + } +} diff --git a/protocol/aprs/mice.go b/protocol/aprs/mice.go new file mode 100644 index 0000000..a536a6c --- /dev/null +++ b/protocol/aprs/mice.go @@ -0,0 +1,428 @@ +package aprs + +import ( + "bytes" + "errors" + "fmt" +) + +type MessageType int + +const ( + MsgEmergency MessageType = iota + MsgPriority + MsgSpecial + MsgCommitted + MsgReturning + MsgInService + MsgEnRoute + MsgOffDuty + MsgCustom0 + MsgCustom1 + MsgCustom2 + MsgCustom3 + MsgCustom4 + MsgCustom5 + MsgCustom6 +) + +type MicE struct { + // Core + Latitude float64 + Longitude float64 + Ambiguity int + Velocity *Velocity + Symbol string + Type MessageType + HasMessaging bool + + // Extensions + Altitude float64 // in meters + DAO *DAO + Telemetry *Telemetry + Comment string + + // Raw + RawDest string + RawInfo []byte +} + +func (report *MicE) String() string { + return report.Comment +} + +type DAO struct { + LatOffset float64 + LonOffset float64 +} + +type micEDecoder struct{} + +func (d micEDecoder) CanDecode(frame *Frame) bool { + if len(frame.Destination.Call) == 6 && len(frame.Raw) >= 8 { + for i := range 6 { + if _, _, _, _, ok := decodeDestChar(frame.Destination.Call[i]); !ok { + return false + } + } + return true + } + return false +} + +func (d micEDecoder) Decode(frame *Frame) (data Data, err error) { + if len(frame.Destination.Call) < 6 { + return nil, errors.New("destination too short for Mic-E") + } + if len(frame.Raw) < 9 { + return nil, errors.New("info field too short for Mic-E") + } + + var ( + r = new(MicE) + north, west bool + dest = frame.Destination.Call + info = []byte(frame.Raw[1:]) + ) + if r.Latitude, r.Type, r.Ambiguity, north, err = r.decodeLatitude(dest); err != nil { + return + } + if r.Longitude, west, r.Velocity, r.Symbol, err = r.decodeMicELongitudeAndMotion(dest, info); err != nil { + return + } + + if !north { + r.Latitude = -r.Latitude + } + if west { + r.Longitude = -r.Longitude + } + + r.parseExtensions(info[8:]) + + return r, nil +} + +func (r *MicE) decodeLatitude(dest string) ( + lat float64, + msg MessageType, + ambiguity int, + north bool, + err error, +) { + if len(dest) != 6 { + return 0, 0, 0, false, errors.New("aprs: MicE destination must be 6 chars") + } + + var digits [6]int + var msgBits int + + for i := range 6 { + c := dest[i] + + d, amb, nBit, mBit, ok := decodeDestChar(c) + if !ok { + return 0, 0, 0, false, fmt.Errorf("invalid dest char %q", c) + } + + digits[i] = d + + if amb { + ambiguity++ + } + + // Only first 3 chars contain message bits + if i < 3 && mBit { + msgBits |= 1 << (2 - i) + } + + // North bit defined by char 3 + if i == 3 { + north = nBit + } + } + + // Apply ambiguity masking per spec + maskAmbiguity(digits[:], ambiguity) + + deg := digits[0]*10 + digits[1] + min := digits[2]*10 + digits[3] + hun := digits[4]*10 + digits[5] + + if deg > 89 || min > 59 || hun > 99 { + return 0, 0, 0, false, errors.New("invalid latitude range") + } + + lat = float64(deg) + + float64(min)/60.0 + + float64(hun)/6000.0 + + if !north { + lat = -lat + } + + return lat, r.interpretMessage(msgBits), ambiguity, north, nil +} + +func decodeDestChar(c byte) ( + digit int, + ambiguity bool, + northBit bool, + msgBit bool, + ok bool, +) { + switch { + case c >= '0' && c <= '9': + return int(c - '0'), false, true, false, true + + case c >= 'A' && c <= 'J': + return int(c - 'A'), false, true, true, true + + case c >= 'P' && c <= 'Y': + return int(c - 'P'), false, false, true, true + + case c == 'K' || c == 'L' || c == 'Z': + return 0, true, true, false, true + + default: + return 0, false, false, false, false + } +} + +func maskAmbiguity(digits []int, ambiguity int) { + for i := 0; i < ambiguity && i < len(digits); i++ { + idx := len(digits) - 1 - i + digits[idx] = 0 + } +} + +func (r *MicE) decodeMicELongitudeAndMotion(dest string, info []byte) (lon float64, west bool, velocity *Velocity, symbol string, err error) { + if len(info) < 3 { + err = errors.New("info too short for longitude") + return + } + + d := int(info[0]) - 28 + m := int(info[1]) - 28 + h := int(info[2]) - 28 + if d < 0 || m < 0 || h < 0 { + err = errors.New("invalid longitude encoding") + return + } + + // 100° offset bit from dest[4] + if dest[4] >= 'P' { + d += 100 + } + + // Wrap correction + if d >= 180 { + d -= 80 + } + + if m >= 60 { + m -= 60 + d++ + } + + lon = float64(d) + + float64(m)/60.0 + + float64(h)/6000.0 + + // East/West from dest[5] + west = dest[5] >= 'P' + if west { + lon = -lon + } + + // Speed/course + if len(info) >= 6 { + s1 := int(info[3]) - 28 + s2 := int(info[4]) - 28 + s3 := int(info[5]) - 28 + + if !(s1 < 0 || s2 < 0 || s3 < 0) { + speed := s1*10 + s2/10 + course := (s2%10)*100 + s3 + + if speed >= 800 { + speed -= 800 + } + if course >= 400 { + course -= 400 + } + + if course >= 360 { + course %= 360 + } + + velocity = &Velocity{ + Speed: knotsToMetersPerSecond(float64(speed)), + Course: course, + } + } + } + + // Symbol + if len(info) >= 8 { + symbol = string([]byte{info[7], info[6]}) + } + + return +} + +func (report *MicE) interpretMessage(bits int) MessageType { + if bits == 0 { + return MsgEmergency + } + if bits <= 7 { + return MessageType(bits) + } + return MsgCustom0 +} + +func (report *MicE) parseExtensions(info []byte) { + info = report.parseOldTelemetry(info) + info = report.parseNewTelemetry(info) + info = report.parseAltitude(info) + info = report.parseDAO(info) + info = report.parseRadioPrefix(info) + + // Remainder is comment + if len(info) > 0 { + report.Comment = string(info) + } +} + +func (report *MicE) parseRadioPrefix(info []byte) []byte { + if len(info) == 0 { + return info + } + + switch info[0] { + case '>', ']': // Kenwood + return info[1:] + case '`': // Yaesu / Byonics / etc. + report.HasMessaging = true + return info[1:] + case '\'': // Yaesu / Byonics / etc. + return info[1:] + default: + return info + } +} + +func (report *MicE) parseOldTelemetry(info []byte) []byte { + if len(info) < 1 { + return info + } + + b := info[0] + if b != '`' && b != '\'' { + return info + } + + // Need 6 bytes total + if 6 > len(info) { + return info // not fatal, ignore malformed + } + + data := info[1:6] + values := make([]int, 5) + for i := 0; i < 5; i++ { + values[i] = int(data[i] - 33) + } + + report.Telemetry = &Telemetry{ + Analog: values, + } + + return info[6:] +} + +func (report *MicE) parseNewTelemetry(info []byte) []byte { + i := bytes.IndexByte(info, '|') + if i == -1 { + return info + } + + prefix, data := info[:i], info[i+1:] + if i = bytes.IndexByte(data, '|'); i == -1 { + return info + } + suffix, data := data[i+1:], data[:i] + if len(data) < 2 || len(data)%2 != 0 { + return info + } + + report.Telemetry = new(Telemetry) + var err error + if report.Telemetry.ID, err = base91Decode(string(data[:2])); err != nil { + return info + } + data = data[2:] + + for range 5 { + if len(data) == 0 { + break + } + var value int + if value, err = base91Decode(string(data[:2])); err != nil { + return info + } + report.Telemetry.Analog = append(report.Telemetry.Analog, value) + data = data[2:] + } + + if len(data) > 0 { + var digital int + if digital, err = base91Decode(string(data[:2])); err != nil { + return info + } + for range 8 { + report.Telemetry.Digital = append(report.Telemetry.Digital, (digital&1) == 1) + digital >>= 1 + } + } + + return append(prefix, suffix...) +} + +func (report *MicE) parseAltitude(info []byte) []byte { + if 4 > len(info) { + return info + } + + if info[3] != '}' { + return info + } + + alt, err := base91Decode(string(info[:3])) + if err != nil { + return info + } + value := alt - 10000 + report.Altitude = feetToMeters(float64(value)) + + return info[4:] +} + +func (report *MicE) parseDAO(info []byte) []byte { + if 6 > len(info) { + return info + } + + if info[0] != '!' || info[1] != 'W' { + return info + } + + latOff := float64(info[2]-33) / 10000.0 + lonOff := float64(info[3]-33) / 10000.0 + + report.DAO = &DAO{ + LatOffset: latOff, + LonOffset: lonOff, + } + + return info[6:] +} diff --git a/protocol/aprs/mice_test.go b/protocol/aprs/mice_test.go new file mode 100644 index 0000000..610a169 --- /dev/null +++ b/protocol/aprs/mice_test.go @@ -0,0 +1,130 @@ +package aprs + +import ( + "reflect" + "testing" +) + +func TestPositionMicE(t *testing.T) { + tests := []struct { + Name string + Frame string + Want *MicE + }{ + { + "position", + "N0CALL>S32U6T:`(_f \"Oj/", + &MicE{ + Latitude: 33.427333, + Longitude: 13.129000, + Symbol: "/j", + Velocity: &Velocity{Course: 251, Speed: 20.57777776}, + }, + }, + { + "position with altitude", + "N0CALL>S32U6T:`(_f \"Oj/\"4T}", + &MicE{ + Latitude: 33.427333, + Longitude: 13.129000, + Altitude: feetToMeters(61), + Symbol: "/j", + Velocity: &Velocity{Course: 251, Speed: 20.57777776}, + }, + }, + { + "position with telemetry", + "N0CALL>S32U6T:`(_f \"Oj/>|\\'s%0\\'c|", + &MicE{ + Latitude: 33.427333, + Longitude: 13.129000, + Symbol: "/j", + Velocity: &Velocity{Course: 251, Speed: 20.57777776}, + Telemetry: &Telemetry{ID: 5375, Analog: []int{7466, 1424, 612}}, + }, + }, + /* + { + "gridsquare position with comment", + "NOCALL>S32U6T:IO91SX/G Hello world", + &Position{ + Latitude: 33.427333, + Longitude: -12.129, + Symbol: "/G", + Comment: "Hello world", + }, + }, + */ + } + + var decoder micEDecoder + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + frame, err := Parse(test.Frame) + if err != nil { + t.Fatalf("can't parse %q: %v", test.Frame, err) + } + if !decoder.CanDecode(frame) { + t.Fatalf("%T can't decode %q", decoder, test.Frame) + } + + v, err := decoder.Decode(frame) + if err != nil { + t.Fatalf("can't decode %q: %v", test.Frame, err) + } + + testCompareMicE(t, test.Want, v) + }) + } +} + +func testCompareMicE(t *testing.T, want *MicE, value any) { + t.Helper() + + p, ok := value.(*MicE) + if !ok { + t.Fatalf("expected data to be a %T, got %T", want, value) + return + } + + if p.HasMessaging != want.HasMessaging { + t.Errorf("expected to have messaging: %t, got %t", want.HasMessaging, p.HasMessaging) + } + + if !testAlmostEqual(p.Latitude, want.Latitude) { + t.Errorf("expected latitude %f, got %f", want.Latitude, p.Latitude) + } + if !testAlmostEqual(p.Longitude, want.Longitude) { + t.Errorf("expected longitude %f, got %f", want.Longitude, p.Longitude) + } + if !testAlmostEqual(p.Altitude, want.Altitude) { + t.Errorf("expected altitude %f, got %f", want.Altitude, p.Altitude) + } + + if p.Symbol != want.Symbol { + t.Errorf("expected symbol %q, got %q", want.Symbol, p.Symbol) + } + if p.Comment != want.Comment { + t.Errorf("expected comment %q, got %q", want.Comment, p.Comment) + } + + if want.Velocity != nil { + if p.Velocity == nil { + t.Errorf("expected velocity, got none") + } else if !reflect.DeepEqual(p.Velocity, want.Velocity) { + t.Errorf("expected velocity %#+v, got %#+v", want.Velocity, p.Velocity) + } + } else if p.Velocity != nil { + t.Errorf("expected no velocity, got %#+v", p.Velocity) + } + + if want.Telemetry != nil { + if p.Telemetry == nil { + t.Errorf("expected telemetry, got none") + } else if !reflect.DeepEqual(p.Telemetry, want.Telemetry) { + t.Errorf("expected telemetry %#+v, got %#+v", want.Telemetry, p.Telemetry) + } + } else if p.Telemetry != nil { + t.Errorf("expected no telemetry, got %#+v", p.Telemetry) + } +} diff --git a/protocol/aprs/packet.go b/protocol/aprs/packet.go deleted file mode 100644 index 8847823..0000000 --- a/protocol/aprs/packet.go +++ /dev/null @@ -1,481 +0,0 @@ -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:"-"` - - // 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 - } -} diff --git a/protocol/aprs/packet_test.go b/protocol/aprs/packet_test.go deleted file mode 100644 index 9b0403a..0000000 --- a/protocol/aprs/packet_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package aprs - -import ( - "math" - "testing" - "time" -) - -const earthRadius = float64(6378100) - -func testTime(day, hour, min, sec int) *time.Time { - t := time.Date(0, 0, day, hour, min, sec, 0, time.UTC) - return &t -} - -// haversin(θ) function -func testHaversin(theta float64) float64 { - return math.Pow(math.Sin(theta/2), 2) -} - -func testDistance(a *Position, b *Position) float64 { - var ( - lat1 = a.Latitude * math.Pi / 180 - lng1 = a.Longitude * math.Pi / 180 - lat2 = b.Latitude * math.Pi / 180 - lng2 = b.Longitude * math.Pi / 180 - h = testHaversin(lat2-lat1) + math.Cos(lat1)*math.Cos(lat2)*testHaversin(lng2-lng1) - ) - return 2 * earthRadius * math.Asin(math.Sqrt(h)) -} - -func TestPacket(t *testing.T) { - var tests = []struct { - Raw string - Src Address - Dst Address - PathLen int - Type DataType - Position *Position - Velocity *Velocity - PHG *PowerHeightGain - DFS *OmniDFStrength - Altitude float64 - Range float64 - Time *time.Time - }{ - { - Raw: "N0CALL>APRS,qAC:!4903.50N/07201.75W-Test 001234", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('!'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - }, - { - Raw: "N0CALL>APRS,qAC:!4903.50N/07201.75W-Test /A=001234", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('!'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - }, - { - Raw: "N0CALL>APRS,qAC:!49 . N/072 . W-", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('!'), - Position: &Position{Latitude: 49.0, Longitude: -72.000000}, - }, - /* - { - Raw: "N0CALL>APRS,qAC:TheNet X-1J4 (BFLD)!4903.50N/07201.75Wn", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('!'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - }, - */ - { - Raw: "N0CALL>APRS,qAC:@092345/4903.50N/07201.75W>Test1234", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('@'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - Time: testTime(9, 23, 45, 0), - }, - { - Raw: "N0CALL>APRS,qAC:=4903.50N/07201.75W#PHG5132", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('='), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - PHG: &PowerHeightGain{'5', '1', '3', '2'}, - }, - { - Raw: "N0CALL>APRS,qAC:=4903.50N/07201.75W 225/000g000t050r000p001...h00b10138dU2k", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('='), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - }, - { - Raw: "N0CALL>APRS,qAC:@092345/4903.50N/07201.75W>088/036", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('@'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - Time: testTime(9, 23, 45, 0), - }, - { - Raw: "N0CALL>APRS,qAC:@234517h4903.50N/07201.75W>PHG5132", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('@'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - Time: testTime(0, 23, 45, 17), - PHG: &PowerHeightGain{'5', '1', '3', '2'}, - }, - { - Raw: "N0CALL>APRS,qAC:@092345z4903.50N/07201.75W>RNG0050", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('@'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - Time: testTime(9, 23, 45, 0), - Range: 50, - }, - { - Raw: "N0CALL>APRS,qAC:/234517h4903.50N/07201.75W>DFS2360", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('/'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - Time: testTime(0, 23, 45, 17), - DFS: &OmniDFStrength{'2', '3', '6', '0'}, - }, - { - Raw: "N0CALL>APRS,qAC:@092345z4903.50N/07201.75W 090/000g000t066r000p000...dUII", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('@'), - Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, - Time: testTime(9, 23, 45, 00), - }, - { - Raw: "N0CALL>APRS,qAC:[IO91SX] 35 miles NNW of London", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('['), - Position: &Position{Latitude: 51.958333, Longitude: -0.500000}, - }, - { - Raw: "N0CALL>APRS,qAC:[IO91]", - Src: MustParseAddress("N0CALL"), - Dst: MustParseAddress("APRS"), - PathLen: 1, - Type: DataType('['), - Position: &Position{Latitude: 51.0, Longitude: -2.0}, - }, - { - Raw: "WX4GSO-9>APN382,qAR,WD4LSS:!3545.18NL07957.08W#PHG5680/R,W,85NC,NCn Mount Shepherd Piedmont Triad NC", - Src: MustParseAddress("WX4GSO-9"), - Dst: MustParseAddress("APN382"), - PathLen: 2, - Type: DataType('!'), - Position: &Position{Latitude: 35.753000, Longitude: -79.951333}, - }, - { - Raw: "PA4TW>APRS,qAS,PA4TW-2:=5216.28N/00510.05Er Remco, DMR:2041014, Soms QRV op PI2NOS", - Src: MustParseAddress("PA4TW"), - Dst: MustParseAddress("APRS"), - PathLen: 2, - Type: DataType('='), - Position: &Position{Latitude: 52.271333, Longitude: 5.167500}, - }, - { - Raw: "PA4TW-10>APRS,TCPIP*,qAC,FOURTH:=5220.18N/00453.25EIhttp://aprs.pa4tw.nl:14501/", - Src: MustParseAddress("PA4TW-10"), - Dst: MustParseAddress("APRS"), - PathLen: 3, - Type: DataType('='), - Position: &Position{Latitude: 52.336333, Longitude: 4.887500}, - }, - { - Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7> sTComment", - Src: MustParseAddress("N0CALL-1"), - Dst: MustParseAddress("T3PY1Y"), - PathLen: 4, - Type: DataType('='), - Position: &Position{Latitude: 49.5, Longitude: -72.75}, - }, - { - Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7>7P[", - Src: MustParseAddress("N0CALL-1"), - Dst: MustParseAddress("T3PY1Y"), - PathLen: 4, - Type: DataType('='), - Position: &Position{Latitude: 49.5, Longitude: -72.75}, - Velocity: &Velocity{88.0, 36.2}, - }, - { - Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7>{?!", - Src: MustParseAddress("N0CALL-1"), - Dst: MustParseAddress("T3PY1Y"), - PathLen: 4, - Type: DataType('='), - Position: &Position{Latitude: 49.5, Longitude: -72.75}, - Range: 20.13, - }, - { - Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7OS]S", - Src: MustParseAddress("N0CALL-1"), - Dst: MustParseAddress("T3PY1Y"), - PathLen: 4, - Type: DataType('='), - Position: &Position{Latitude: 49.5, Longitude: -72.75}, - Altitude: 10004, - }, - { - Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:@092345z/5L!!<*e7>{?!", - Src: MustParseAddress("N0CALL-1"), - Dst: MustParseAddress("T3PY1Y"), - PathLen: 4, - Type: '@', - Time: testTime(9, 23, 45, 0), - Position: &Position{Latitude: 49.5, Longitude: -72.75}, - Range: 20.13, - }, - { - Raw: "PE1ROG-8>APLC13,qAS,PE1ROG-2:!/49[
8pQLoRa___MOBIL___TEST",
- Src: MustParseAddress("PE1ROG-8"),
- Dst: MustParseAddress("APLC13"),
- PathLen: 2,
- Type: '!',
- },
- {
- Raw: "PE1ROG-1>APPM13,TCPIP*,qAC,T2PRT:>Running on Raspberry Pi with RTL dongle",
- Src: MustParseAddress("PE1ROG-1"),
- Dst: MustParseAddress("APPM13"),
- PathLen: 3,
- Type: '>',
- },
- {
- Raw: "PD0MZ-4>APE32L,WIDE1-1,qAR,PD0MZ-10:>LoRa APRS Tracker",
- Src: MustParseAddress("PD0MZ-4"),
- Dst: MustParseAddress("APE32L"),
- PathLen: 3,
- Type: '>',
- },
- }
-
- for _, test := range tests {
- t.Run(test.Raw, func(t *testing.T) {
- p, err := ParsePacket(test.Raw)
- if err != nil {
- t.Fatal(err)
- }
- if !p.Dst.EqualTo(test.Dst) {
- t.Fatalf("expected dst %s, got %s", test.Dst, p.Dst)
- }
- if !p.Src.EqualTo(test.Src) {
- t.Fatalf("expected src %s, got %s", test.Src, p.Src)
- }
- if len(p.Path) != test.PathLen {
- t.Fatalf("expected path length %d, got %d: %v", test.PathLen, len(p.Path), p.Path)
- }
- if p.Payload.Type() != test.Type {
- t.Fatalf("expected packet type %s [%c], got %s [%c]",
- test.Type, test.Type,
- p.Payload.Type(), p.Payload.Type())
- }
- if test.Altitude != 0 {
- if p.Altitude == 0 {
- t.Fatalf("expected altitude %f, got none", test.Altitude)
- }
- if math.Abs(test.Altitude-p.Altitude) > 1.0 {
- t.Fatalf("expected altitude %f, got %f", test.Altitude, p.Altitude)
- }
- }
- if test.Velocity != nil {
- if p.Velocity.Course == 0 {
- t.Fatalf("expected velocity %v, got none", test.Velocity)
- }
- if math.Abs(test.Velocity.Course-p.Velocity.Course) > 1.0 {
- t.Fatalf("expected course %f, got %f", test.Velocity.Course, p.Velocity.Course)
- }
- if math.Abs(test.Velocity.Speed-p.Velocity.Speed) > 1.0 {
- t.Fatalf("expected speed %f, got %f", test.Velocity.Speed, p.Velocity.Speed)
- }
- }
- if test.Range != 0 {
- if p.Range == 0 {
- t.Fatalf("expected range %f, got none", test.Range)
- }
- if math.Abs(test.Range-p.Range) > 0.1 {
- t.Fatalf("expected range %f, got %f", test.Range, p.Range)
- }
- }
- if test.Position != nil {
- if p.Position == nil {
- t.Fatalf("expected position %s, got none", test.Position)
- }
- if d := testDistance(test.Position, p.Position); d > 1.0 {
- t.Fatalf("expected position %s, got %s with distance %f meter", test.Position, p.Position, d)
- }
- }
- if test.Time != nil {
- if p.Time == nil {
- t.Fatalf("expected time %s", test.Time)
- }
- if test.Time.Sub(*p.Time) > time.Minute {
- t.Fatalf("expected time %s, got %s", test.Time, p.Time)
- }
- }
- //t.Logf("%#+v", p)
- })
- }
-}
diff --git a/protocol/aprs/position.go b/protocol/aprs/position.go
index ec0905c..94bf768 100644
--- a/protocol/aprs/position.go
+++ b/protocol/aprs/position.go
@@ -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
}
diff --git a/protocol/aprs/position_test.go b/protocol/aprs/position_test.go
new file mode 100644
index 0000000..3ced958
--- /dev/null
+++ b/protocol/aprs/position_test.go
@@ -0,0 +1,433 @@
+package aprs
+
+import (
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestParseBase91Telemetry(t *testing.T) {
+ tests := []struct {
+ Test string
+ Telemetry *Telemetry
+ Comment string
+ }{
+ {
+ "|!!!!|",
+ &Telemetry{Analog: []int{0}},
+ "",
+ },
+ {
+ "|ss11|",
+ &Telemetry{ID: 7544, Analog: []int{1472}},
+ "",
+ },
+ {
+ "|ss112233|",
+ &Telemetry{ID: 7544, Analog: []int{1472, 1564, 1656}},
+ "",
+ },
+ {
+ "|ss1122334455!\"|",
+ &Telemetry{ID: 7544, Analog: []int{1472, 1564, 1656, 1748, 1840}, Digital: []bool{true, false, false, false, false, false, false, false}},
+ "",
+ },
+ {
+ "|ss11|73's de N0CALL",
+ &Telemetry{ID: 7544, Analog: []int{1472}},
+ "73's de N0CALL",
+ },
+ {
+ "`pZ3l-B]/'\"6{}|!9'X$u|!wr8!|3",
+ &Telemetry{ID: 24, Analog: []int{601, 357}},
+ "`pZ3l-B]/'\"6{}!wr8!|3",
+ },
+ {
+ "!/0%3RTh<6>dS_http://aprs.fi/|\"p%T'.ag|",
+ &Telemetry{ID: 170, Analog: []int{415, 559, 5894}},
+ "!/0%3RTh<6>dS_http://aprs.fi/",
+ },
+ {
+ "!6304.03NN02739.63E#PHG26303/Siilinjarvi|\"p%T'.agff|",
+ &Telemetry{ID: 170, Analog: []int{415, 559, 5894, 6348}},
+ "!6304.03NN02739.63E#PHG26303/Siilinjarvi",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.Test, func(t *testing.T) {
+ v, comment, err := parseBase91Telemetry(test.Test)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (v == nil) != (test.Telemetry == nil) {
+ t.Fatalf("expected telemetry %#+v, got %#+v", test.Telemetry, v)
+ }
+ if test.Telemetry != nil {
+ if v.ID != test.Telemetry.ID {
+ t.Errorf("expected id %d, got %d", test.Telemetry.ID, v.ID)
+ }
+ if !reflect.DeepEqual(v.Analog, test.Telemetry.Analog) {
+ t.Errorf("expected analog values %d, got %d", test.Telemetry.Analog, v.Analog)
+ }
+ if !reflect.DeepEqual(v.Digital, test.Telemetry.Digital) {
+ t.Errorf("expected digital values %t, got %t", test.Telemetry.Digital, v.Digital)
+ }
+ }
+ if comment != test.Comment {
+ t.Errorf("expected comment %q, got %q", test.Comment, comment)
+ }
+ })
+ }
+}
+
+func TestParsePosition(t *testing.T) {
+ localTime := time.Now()
+ tests := []struct {
+ Name string
+ Raw Raw
+ Want *Position
+ }{
+ {
+ "no timestamp, no APRS messaging, with comment",
+ "!4903.50N/07201.75W-Test 001234",
+ &Position{
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/-",
+ Comment: "Test 001234",
+ },
+ },
+ {
+ "no timestamp, no APRS messaging, altitude = 1234 ft",
+ "!4903.50N/07201.75W-Test /A=001234",
+ &Position{
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Altitude: 376.1232,
+ Symbol: "/-",
+ Comment: "Test ",
+ },
+ },
+ {
+ "no timestamp, no APRS messaging, location to nearest degree",
+ "!49 . N/072 . W-",
+ &Position{
+ Latitude: 49,
+ Longitude: -72,
+ Symbol: "/-",
+ },
+ },
+ {
+ "with timestamp, no APRS messaging, zulu time, with comment",
+ "/092345z4903.50N/07201.75W>Test1234",
+ &Position{
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/>",
+ Comment: "Test1234",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, time.UTC),
+ },
+ },
+ {
+ "with timestamp, with APRS messaging, local time, with comment",
+ "@092345/4903.50N/07201.75W>Test1234",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/>",
+ Comment: "Test1234",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, localTime.Location()),
+ },
+ },
+ {
+ "no timestamp, with APRS messaging, with PHG",
+ "=4903.50N/07201.75W#PHG5132",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/#",
+ },
+ },
+ {
+ "weather report",
+ "=4903.50N/07201.75W 225/000g000t050r000p001h00b10138dU2k",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/ ",
+ Comment: "dU2k",
+ Velocity: &Velocity{Course: 225},
+ Weather: &Weather{Temperature: 10, Rain24h: 0.254, Pressure: 1013.8},
+ },
+ },
+ {
+ "with timestamp, with APRS messaging, local time, course/speed",
+ "@092345/4903.50N/07201.75W>088/036",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/>",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, localTime.Location()),
+ Velocity: &Velocity{Course: 88, Speed: 18.519999984000002},
+ },
+ },
+ {
+ "with timestamp, with APRS messaging, hours/mins/secs time, PHG",
+ "@234517h4903.50N/07201.75W>PHG5132",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/>",
+ Time: time.Date(0, 0, 0, 23, 45, 17, 0, time.UTC),
+ },
+ },
+ {
+ "with timestamp, with APRS messaging, zulu time, radio range",
+ "@092345z4903.50N/07201.75W>RNG0050",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/>",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, time.UTC),
+ },
+ },
+ {
+ "with timestamp, hours/mins/secs time, DF, no APRS messaging",
+ "/234517h4903.50N/07201.75W>DFS2360",
+ &Position{
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/>",
+ Time: time.Date(0, 0, 0, 23, 45, 17, 0, localTime.Location()),
+ },
+ },
+ {
+ "with timestamp, APRS messaging, zulu time, weather report",
+ "@092345z4903.50N/07201.75W 090/000g000t066r000p000dUII",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/ ",
+ Comment: "dUII",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, time.UTC),
+ Velocity: &Velocity{Course: 90},
+ Weather: &Weather{Temperature: 18.88888888888889},
+ },
+ },
+ {
+ "no timestamp, course/speed/bearing/NRQ, with APRS messaging, DF station moving",
+ "=4903.50N/07201.75W\\088/036/270/729",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/\\",
+ Velocity: &Velocity{Course: 88, Speed: knotsToMetersPerSecond(36)},
+ Wind: &Wind{Direction: 270, Speed: knotsToMetersPerSecond(729)},
+ },
+ },
+ {
+ "no timestamp, course/speed/bearing/NRQ, with APRS messaging, DF station fixed",
+ "=4903.50N/07201.75W\\000/036/270/729",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/\\",
+ Velocity: &Velocity{Course: 0, Speed: knotsToMetersPerSecond(36)},
+ Wind: &Wind{Direction: 270, Speed: knotsToMetersPerSecond(729)},
+ },
+ },
+ {
+ "with timestamp, course/speed/bearing/NRQ, with APRS messaging",
+ "@092345z4903.50N/07201.75W\\088/036/270/729",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/\\",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, time.UTC),
+ Velocity: &Velocity{Course: 88, Speed: knotsToMetersPerSecond(36)},
+ Wind: &Wind{Direction: 270, Speed: knotsToMetersPerSecond(729)},
+ },
+ },
+ {
+ "with timestamp, bearing/NRQ, no course/speed, no APRS messaging",
+ "/092345z4903.50N/07201.75W\\000/000/270/729",
+ &Position{
+ Latitude: 49.058333,
+ Longitude: -72.029167,
+ Symbol: "/\\",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, time.UTC),
+ Velocity: &Velocity{},
+ Wind: &Wind{Direction: 270, Speed: knotsToMetersPerSecond(729)},
+ },
+ },
+
+ // compressed positions:
+
+ {
+ "compressed, with APRS messaging",
+ "=/5L!!<*e7> sTComment",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.5,
+ Longitude: -72.750004,
+ Symbol: "/>",
+ Comment: "Comment",
+ },
+ },
+ {
+ "compressed, with APRS messaging, RMC sentence, with course/speed",
+ "=/5L!!<*e7>7P[",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.5,
+ Longitude: -72.750004,
+ Symbol: "/>",
+ },
+ },
+ {
+ "compressed, with APRS messaging, with radio range",
+ "=/5L!!<*e7>{?!",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.5,
+ Longitude: -72.750004,
+ Symbol: "/>",
+ },
+ },
+ {
+ "compressed, with APRS messaging, GGA sentence, altitude",
+ "=/5L!!<*e7OS]S",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.5,
+ Longitude: -72.750004,
+ Symbol: "/O",
+ },
+ },
+ {
+ "compressed, with APRS messaging, timestamp, radio range",
+ "@092345z/5L!!<*e7>{?!",
+ &Position{
+ HasMessaging: true,
+ Latitude: 49.5,
+ Longitude: -72.750004,
+ Symbol: "/>",
+ Time: time.Date(localTime.Year(), localTime.Month(), 9, 23, 45, 0, 0, time.UTC),
+ },
+ },
+ }
+
+ var decoder positionDecoder
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ frame := &Frame{Raw: test.Raw}
+ if !decoder.CanDecode(frame) {
+ t.Fatalf("%T can't decode %q", decoder, test.Raw)
+ }
+
+ v, err := decoder.Decode(frame)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testComparePosition(t, test.Want, v)
+ })
+ }
+
+}
+
+func testComparePosition(t *testing.T, want *Position, value any) {
+ t.Helper()
+
+ p, ok := value.(*Position)
+ if !ok {
+ t.Fatalf("expected data to be a %T, got %T", want, value)
+ return
+ }
+
+ if p.HasMessaging != want.HasMessaging {
+ t.Errorf("expected to have messaging: %t, got %t", want.HasMessaging, p.HasMessaging)
+ }
+
+ if !testAlmostEqual(p.Latitude, want.Latitude) {
+ t.Errorf("expected latitude %f, got %f", want.Latitude, p.Latitude)
+ }
+ if !testAlmostEqual(p.Longitude, want.Longitude) {
+ t.Errorf("expected longitude %f, got %f", want.Longitude, p.Longitude)
+ }
+ if !testAlmostEqual(p.Altitude, want.Altitude) {
+ t.Errorf("expected altitude %f, got %f", want.Altitude, p.Altitude)
+ }
+
+ if p.Symbol != want.Symbol {
+ t.Errorf("expected symbol %q, got %q", want.Symbol, p.Symbol)
+ }
+ if p.Comment != want.Comment {
+ t.Errorf("expected comment %q, got %q", want.Comment, p.Comment)
+ }
+
+ if want.Time.Equal(time.Time{}) {
+ if !p.Time.Equal(time.Time{}) {
+ t.Errorf("expected no time stamp, got %s", p.Time)
+ }
+ } else if want.Time.Year() == -1 {
+ if p.Time.Hour() != want.Time.Hour() ||
+ p.Time.Minute() != want.Time.Minute() ||
+ p.Time.Second() != want.Time.Second() {
+ t.Errorf("expected time %s, got %s", want.Time.Format("15:04:05"), p.Time.Format("15:04:05"))
+ }
+ } else if !want.Time.Equal(p.Time) {
+ t.Errorf("expected time %s, got %s", want.Time, p.Time)
+ }
+
+ if want.Velocity != nil {
+ if p.Velocity == nil {
+ t.Errorf("expected velocity, got none")
+ } else if !reflect.DeepEqual(p.Velocity, want.Velocity) {
+ t.Errorf("expected velocity %#+v, got %#+v", want.Velocity, p.Velocity)
+ }
+ } else if p.Velocity != nil {
+ t.Errorf("expected no velocity, got %#+v", p.Velocity)
+ }
+
+ if want.Wind != nil {
+ if p.Wind == nil {
+ t.Errorf("expected wind, got none")
+ } else if !reflect.DeepEqual(p.Wind, want.Wind) {
+ t.Errorf("expected wind %#+v, got %#+v", want.Wind, p.Wind)
+ }
+ } else if p.Wind != nil {
+ t.Errorf("expected no wind, got %#+v", p.Wind)
+ }
+
+ if want.Telemetry != nil {
+ if p.Telemetry == nil {
+ t.Errorf("expected telemetry, got none")
+ } else if !reflect.DeepEqual(p.Telemetry, want.Telemetry) {
+ t.Errorf("expected telemetry %#+v, got %#+v", want.Telemetry, p.Telemetry)
+ }
+ } else if p.Telemetry != nil {
+ t.Errorf("expected no telemetry, got %#+v", p.Telemetry)
+ }
+
+ if want.Weather != nil {
+ if p.Weather == nil {
+ t.Errorf("expected weather, got none")
+ } else if !reflect.DeepEqual(p.Weather, want.Weather) {
+ t.Errorf("expected weather %#+v, got %#+v", want.Weather, p.Weather)
+ }
+ } else if p.Weather != nil {
+ t.Errorf("expected no weather, got %#+v", p.Weather)
+ }
+}
diff --git a/protocol/aprs/query.go b/protocol/aprs/query.go
new file mode 100644
index 0000000..7f09240
--- /dev/null
+++ b/protocol/aprs/query.go
@@ -0,0 +1,50 @@
+package aprs
+
+import (
+ "strconv"
+ "strings"
+)
+
+type Query struct {
+ Type string `json:"type"`
+ Latitude float64
+ Longitude float64
+ Radius float64 // radius in meters
+}
+
+func (q Query) String() string {
+ return q.Type
+}
+
+type queryDecoder struct{}
+
+func (queryDecoder) CanDecode(frame *Frame) bool {
+ return len(frame.Raw) >= 3 && frame.Raw.Type() == '?'
+}
+
+func (queryDecoder) Decode(frame *Frame) (data Data, err error) {
+ var (
+ kind = string(frame.Raw[1:])
+ args string
+ i int
+ )
+ if i = strings.IndexByte(kind, '?'); i == -1 {
+ return &Query{
+ Type: kind,
+ }, nil
+ } else {
+ kind, args = kind[:i], kind[i+1:]
+ }
+
+ query := &Query{Type: kind}
+
+ if part := strings.SplitN(strings.TrimSpace(args), ",", 3); len(part) == 3 {
+ var radius int
+ query.Latitude, _ = strconv.ParseFloat(part[0], 64)
+ query.Longitude, _ = strconv.ParseFloat(part[1], 64)
+ radius, _ = strconv.Atoi(part[2])
+ query.Radius = milesToMeters(float64(radius))
+ }
+
+ return query, nil
+}
diff --git a/protocol/aprs/query_test.go b/protocol/aprs/query_test.go
new file mode 100644
index 0000000..2ef9608
--- /dev/null
+++ b/protocol/aprs/query_test.go
@@ -0,0 +1,69 @@
+package aprs
+
+import "testing"
+
+func TestParseQuery(t *testing.T) {
+ tests := []struct {
+ Name string
+ Raw Raw
+ Want *Query
+ }{
+ {
+ "general query",
+ "?APRS?",
+ &Query{
+ Type: "APRS",
+ },
+ },
+ {
+ "general query for stations within a target footprint of radius 200 miles",
+ "?APRS? 34.02,-117.15,0200",
+ &Query{
+ Type: "APRS",
+ Latitude: 34.02,
+ Longitude: -117.15,
+ Radius: milesToMeters(200),
+ },
+ },
+ }
+
+ var decoder queryDecoder
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ frame := &Frame{Raw: test.Raw}
+ if !decoder.CanDecode(frame) {
+ t.Fatalf("%T can't decode %q", decoder, test.Raw)
+ }
+
+ v, err := decoder.Decode(frame)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testCompareQuery(t, test.Want, v)
+ })
+ }
+}
+
+func testCompareQuery(t *testing.T, want *Query, value any) {
+ t.Helper()
+
+ test, ok := value.(*Query)
+ if !ok {
+ t.Fatalf("expected data to be a %T, got %T", want, value)
+ return
+ }
+
+ if test.Type != want.Type {
+ t.Errorf("expected type %q, got %q", want.Type, test.Type)
+ }
+ if !testAlmostEqual(test.Latitude, want.Latitude) {
+ t.Errorf("expected latitude %f, got %f", want.Latitude, test.Latitude)
+ }
+ if !testAlmostEqual(test.Longitude, want.Longitude) {
+ t.Errorf("expected longitude %f, got %f", want.Longitude, test.Longitude)
+ }
+ if !testAlmostEqual(test.Radius, want.Radius) {
+ t.Errorf("expected radius %f, got %f", want.Radius, test.Radius)
+ }
+}
diff --git a/protocol/aprs/status.go b/protocol/aprs/status.go
new file mode 100644
index 0000000..6124a1a
--- /dev/null
+++ b/protocol/aprs/status.go
@@ -0,0 +1,92 @@
+package aprs
+
+import (
+ "strings"
+ "time"
+
+ "git.maze.io/go/ham/util/maidenhead"
+)
+
+type Status struct {
+ Time time.Time
+ Latitude float64
+ Longitude float64
+ BeamHeading int
+ ERP int
+ Symbol string
+ Text string
+}
+
+func (s Status) String() string {
+ return s.Text
+}
+
+type statusReportDecoder struct{}
+
+func (statusReportDecoder) CanDecode(frame Frame) bool {
+ return frame.Raw.Type() == '>'
+}
+
+func (statusReportDecoder) Decode(frame Frame) (data Data, err error) {
+ var (
+ text = string(frame.Raw[1:])
+ status = new(Status)
+ )
+ if hasTimestamp(text) {
+ if status.Time, text, err = parseTimestamp(text); err != nil {
+ return
+ }
+ }
+
+ if len(text) > 3 && text[len(text)-3] == '^' {
+ var (
+ h = text[len(text)-2]
+ p = text[len(text)-1]
+ )
+ if h >= '0' && h <= '9' {
+ status.BeamHeading = int(h-'0') * 10
+ } else if h >= 'A' && h <= 'Z' {
+ status.BeamHeading = 100 + int(h-'A')*10
+ }
+ if p >= '0' && p <= 'Z' {
+ status.ERP = int(p-'0') * int(p-'0') * 10
+ }
+ text = text[:len(text)-3]
+ }
+
+ here := text
+ if i := strings.IndexByte(here, ' '); i != -1 {
+ here = here[:i]
+ }
+ switch len(here) {
+ case 6:
+ var point maidenhead.Point
+ if point, err = maidenhead.ParseLocator(here[:4]); err != nil {
+ return
+ }
+ status.Latitude = point.Latitude
+ status.Longitude = point.Longitude
+ status.Symbol = here[4:]
+ if len(text) > 6 {
+ text = text[7:]
+ } else {
+ text = text[6:]
+ }
+ case 8:
+ var point maidenhead.Point
+ if point, err = maidenhead.ParseLocator(here[:6]); err != nil {
+ return
+ }
+ status.Latitude = point.Latitude
+ status.Longitude = point.Longitude
+ status.Symbol = here[6:]
+ if len(text) > 8 {
+ text = text[9:]
+ } else {
+ text = text[8:]
+ }
+ }
+
+ status.Text = text
+ return status, nil
+}
diff --git a/protocol/aprs/status_test.go b/protocol/aprs/status_test.go
new file mode 100644
index 0000000..97ea688
--- /dev/null
+++ b/protocol/aprs/status_test.go
@@ -0,0 +1,120 @@
+package aprs
+
+import "testing"
+
+func TestParseStatusReport(t *testing.T) {
+ tests := []struct {
+ Name string
+ Raw Raw
+ Want *Status
+ }{
+ {
+ "without timestamp",
+ ">Net Control Center",
+ &Status{
+ Text: "Net Control Center",
+ },
+ },
+ {
+ "with timestamp",
+ ">092345zNet Control Center",
+ &Status{
+ Text: "Net Control Center",
+ },
+ },
+ {
+ "with beam heading and erp",
+ ">Test^B7",
+ &Status{
+ Text: "Test",
+ BeamHeading: 110,
+ ERP: 490,
+ },
+ },
+ {
+ "with maidenhead locator",
+ ">IO91SX/G",
+ &Status{
+ Latitude: 51.958333,
+ Longitude: -0.5,
+ Symbol: "/G",
+ },
+ },
+ {
+ "with short maidenhead locator",
+ ">IO91/G",
+ &Status{
+ Latitude: 51,
+ Longitude: -2,
+ Symbol: "/G",
+ },
+ },
+ {
+ "with maidenhead locator and comment",
+ ">IO91SX/- My house",
+ &Status{
+ Latitude: 51.958333,
+ Longitude: -0.5,
+ Symbol: "/-",
+ Text: "My house",
+ },
+ },
+ {
+ "with maidenhead locator and beam heading",
+ ">IO91SX/- ^B7",
+ &Status{
+ Latitude: 51.958333,
+ Longitude: -0.5,
+ Symbol: "/-",
+ BeamHeading: 110,
+ ERP: 490,
+ },
+ },
+ }
+
+ var decoder statusReportDecoder
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ frame := Frame{Raw: test.Raw}
+ if !decoder.CanDecode(frame) {
+ t.Fatalf("%T can't decode %q", decoder, test.Raw)
+ }
+
+ v, err := decoder.Decode(frame)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testCompareStatusReport(t, test.Want, v)
+ })
+ }
+}
+
+func testCompareStatusReport(t *testing.T, want *Status, value any) {
+ t.Helper()
+
+ test, ok := value.(*Status)
+ if !ok {
+ t.Fatalf("expected data to be a %T, got %T", want, value)
+ return
+ }
+
+ if !testAlmostEqual(test.Latitude, want.Latitude) {
+ t.Errorf("expected latitude %f, got %f", want.Latitude, test.Latitude)
+ }
+ if !testAlmostEqual(test.Longitude, want.Longitude) {
+ t.Errorf("expected longitude %f, got %f", want.Longitude, test.Longitude)
+ }
+ if test.BeamHeading != want.BeamHeading {
+ t.Errorf("expected beam heading %d, got %d", want.BeamHeading, test.BeamHeading)
+ }
+ if test.ERP != want.ERP {
+ t.Errorf("expected ERP %dW, got %dW", want.ERP, test.ERP)
+ }
+ if test.Symbol != want.Symbol {
+ t.Errorf("expected symbol %q, got %q", want.Symbol, test.Symbol)
+ }
+ if test.Text != want.Text {
+ t.Errorf("expected text %q, got %q", want.Text, test.Text)
+ }
+}
diff --git a/protocol/aprs/symbol.go b/protocol/aprs/symbol.go
deleted file mode 100644
index abe9e9e..0000000
--- a/protocol/aprs/symbol.go
+++ /dev/null
@@ -1,266 +0,0 @@
-package aprs
-
-import (
- "encoding/json"
- "fmt"
-)
-
-var emptySymbol Symbol
-
-type Symbol [2]byte
-
-func (s Symbol) IsPrimaryTable() bool { return s[0] != '\\' }
-
-func (s Symbol) MarshalJSON() ([]byte, error) {
- if s == emptySymbol {
- return json.Marshal("")
- }
- return json.Marshal(string(s[:]))
-}
-
-func (s Symbol) get(idx int) (string, error) {
- var m map[byte]map[int]string
- if s.IsPrimaryTable() {
- m = primarySymbol
- } else {
- m = alternateSymbol
- }
- n, ok := m[s[1]]
- if !ok {
- return "", fmt.Errorf("unknown symbol %x", s[1])
- }
- if i, ok := n[idx]; ok {
- return i, nil
- }
- return "", fmt.Errorf("symbol doesn't have requested index: %v", n)
-}
-
-func (s Symbol) String() string {
- hr, err := s.get(1)
- if err != nil {
- return err.Error()
- }
- return hr
-}
-
-func (s Symbol) SSID() (string, error) {
- return s.get(2)
-}
-
-func (s Symbol) Emoji() (string, error) {
- return s.get(3)
-}
-
-func IsValidCompressedSymTable(c byte) bool {
- return c == '/' ||
- c == '\\' ||
- (c >= 0x41 && c <= 0x5a) ||
- (c >= 0x61 && c <= 0x6a)
-}
-
-func IsValidUncompressedSymTable(c byte) bool {
- return c == '/' ||
- c == '\\' ||
- (c >= 0x41 && c <= 0x5a) ||
- (c >= 0x30 && c <= 0x39)
-}
-
-var (
- // Source: http://www.aprs.org/symbols/symbolsX.txt
- // 0: XYZ code
- // 1: Human readable
- // 2: SSID
- // 3: Emoji
- primarySymbol = map[byte]map[int]string{
- '!': {0: "BB", 1: "Police, Sheriff", 3: ":cop:"},
- '"': {0: "BC", 1: "reserved"},
- '#': {0: "BD", 1: "Digi"},
- '$': {0: "BE", 1: "Phone", 3: ":phone:"},
- '%': {0: "BF", 1: "DX Cluster"},
- '&': {0: "BG", 1: "HF Gateway"},
- '\'': {0: "BH", 1: "Small Aircraft", 2: "11", 3: ":airplane:"},
- '(': {0: "BI", 1: "Mobile Satellite Station", 3: ":satellite:"},
- ')': {0: "BJ", 1: "Wheelchair", 3: ":wheelchair:"},
- '*': {0: "BK", 1: "Snowmobile"},
- '+': {0: "BL", 1: "Red Cross"},
- ',': {0: "BM", 1: "Boy Scout"},
- '-': {0: "BN", 1: "House QTH (VHF)"},
- '.': {0: "BO", 1: "X"},
- '/': {0: "BP", 1: "Red Dot"},
- '0': {0: "P0", 1: "Circle (0)"},
- '1': {0: "P1", 1: "Circle (1)"},
- '2': {0: "P2", 1: "Circle (2)"},
- '3': {0: "P3", 1: "Circle (3)"},
- '4': {0: "P4", 1: "Circle (4)"},
- '5': {0: "P5", 1: "Circle (5)"},
- '6': {0: "P6", 1: "Circle (6)"},
- '7': {0: "P7", 1: "Circle (7)"},
- '8': {0: "P8", 1: "Circle (8)"},
- '9': {0: "P9", 1: "Circle (9)"},
- ':': {0: "MR", 1: "Fire", 3: ":fire:"},
- ';': {0: "MS", 1: "Campground", 3: ":tent:"},
- '<': {0: "MT", 1: "Motorcycle", 2: "10", 3: ":bike:"},
- '=': {0: "MU", 1: "Railroad Engine", 3: ":train:"},
- '>': {0: "MV", 1: "Car", 2: "9", 3: ":car:"},
- '?': {0: "MW", 1: "File Server"},
- '@': {0: "MX", 1: "HC Future"},
- 'A': {0: "PA", 1: "Aid Station", 3: ":hospital:"},
- 'B': {0: "PB", 1: "BBS or PBBS"},
- 'C': {0: "PC", 1: "Canoe"},
- 'D': {0: "PD"},
- 'E': {0: "PE", 1: "Eyeball"},
- 'F': {0: "PF", 1: "Tractor", 3: ":tractor:"},
- 'G': {0: "PG", 1: "Grid Square"},
- 'H': {0: "PH", 1: "Hotel", 3: ":hotel:"},
- 'I': {0: "PI", 1: "TCP/IP"},
- 'J': {0: "PJ"},
- 'K': {0: "PK", 1: "School", 3: ":school:"},
- 'L': {0: "PL", 1: "PC User", 3: ":computer:"},
- 'M': {0: "PM", 1: "MacAPRS", 3: ":computer:"},
- 'N': {0: "PN", 1: "NTS Station"},
- 'O': {0: "PO", 1: "Balloon", 2: "11", 3: ":airplane:"},
- 'P': {0: "PP", 1: "Police", 3: ":police_car:"},
- 'Q': {0: "PQ"},
- 'R': {0: "PR", 1: "Recreational Vehicle", 2: "13", 3: ":car:"},
- 'S': {0: "PS", 1: "Shuttle"},
- 'T': {0: "PT", 1: "SSTV"},
- 'U': {0: "PU", 1: "Bus", 2: "2", 3: ":bus:"},
- 'V': {0: "PV", 1: "ATV"},
- 'W': {0: "PW", 1: "National WX Service Site"},
- 'X': {0: "PX", 1: "Helo", 2: "6"},
- 'Y': {0: "PY", 1: "Yacht", 2: "5", 3: ":sailboat:"},
- 'Z': {0: "PZ", 1: "WinAPRS", 3: ":computer:"},
- '[': {0: "HS", 1: "Human/Person", 2: "7", 3: ":running:"},
- '\\': {0: "HT", 1: "DF Station"},
- ']': {0: "HU", 1: "Post Office", 3: ":post_office:"},
- '^': {0: "HV", 1: "Large Aircraft", 3: ":airplane:"},
- '_': {0: "HW", 1: "Weather Station", 3: ":cloud:"},
- '`': {0: "HX", 1: "Dish Antenna", 3: ":satellite:"},
- 'a': {0: "LA", 1: "Ambulance", 2: "1", 3: ":ambulance:"},
- 'b': {0: "LB", 1: "Bike", 2: "4", 3: ":bike:"},
- 'c': {0: "LC", 1: "Incident Command Post"},
- 'd': {0: "LD", 1: "Fire Dept", 3: ":fire_engine:"},
- 'e': {0: "LE", 1: "Horse", 3: ":racehorse:"},
- 'f': {0: "LF", 1: "Fire Truck", 2: "3", 3: ":fire_engine:"},
- 'g': {0: "LG", 1: "Glider", 3: ":airplane:"},
- 'h': {0: "LH", 1: "Hospital", 3: ":hospital:"},
- 'i': {0: "LI", 1: "IOTA"},
- 'j': {0: "LJ", 1: "Jeep", 2: "12", 3: ":car:"},
- 'k': {0: "LK", 1: "Truck", 2: "14", 3: ":truck:"},
- 'l': {0: "LL", 1: "Laptop", 3: ":computer:"},
- 'm': {0: "LM", 1: "Mic-E Repeater"},
- 'n': {0: "LN", 1: "Node"},
- 'o': {0: "LO", 1: "EOC"},
- 'p': {0: "LP", 1: "Dog", 3: ":dog2:"},
- 'q': {0: "LQ", 1: "Grid SQ"},
- 'r': {0: "LR", 1: "Repeater"},
- 's': {0: "LS", 1: "Ship", 2: "8", 3: ":ship:"},
- 't': {0: "LT", 1: "Truck Stop"},
- 'u': {0: "LU", 1: "Truck (18 Wheeler)", 3: ":truck:"},
- 'v': {0: "LV", 1: "Van", 2: "15", 3: ":minibus:"},
- 'w': {0: "LW", 1: "Water Station"},
- 'x': {0: "LX", 1: "xAPRS", 3: ":computer:"},
- 'y': {0: "LY", 1: "Yagi @ QTH"},
- 'z': {0: "LZ"},
- '{': {0: "J1"},
- '|': {0: "J2", 1: "TNC Stream Switch"},
- '}': {0: "J3"},
- '~': {0: "J4", 1: "TNC Stream Switch"},
- }
- alternateSymbol = map[byte]map[int]string{
- '!': {0: "OBO", 1: "Emergency"},
- '"': {0: "OC", 1: "Reserved"},
- '#': {0: "OD#", 1: "Overlay Digi"},
- '$': {0: "OEO", 1: "Bank/ATM", 3: ":atm:"},
- '%': {0: "OFO", 1: "Power Plant", 3: ":factory:"},
- '&': {0: "OG#", 1: "I=Igte R=RX T=1hopTX 2=2hopTX"},
- '\'': {0: "OHO", 1: "Crash Site"},
- '(': {0: "OIO", 1: "Cloudy", 3: ":cloud:"},
- ')': {0: "OJO", 1: "Firenet MEO"},
- '*': {0: "OK"},
- '+': {0: "OL", 1: "Church", 3: ":church:"},
- ',': {0: "OM", 1: "Girl Scouts", 3: ":tent:"},
- '-': {0: "ONO", 1: "House", 3: ":house:"},
- '.': {0: "OO", 1: "Ambiguous"},
- '/': {0: "OP", 1: "Waypoint Destination"},
- '0': {0: "A0#", 1: "Circle", 3: ":red_circle:"},
- '1': {0: "A1"},
- '2': {0: "A2"},
- '3': {0: "A3"},
- '4': {0: "A4"},
- '5': {0: "A5"},
- '6': {0: "A6"},
- '7': {0: "A7"},
- '8': {0: "A8O", 1: "WiFi Network"},
- '9': {0: "A9", 1: "Gas Station", 3: ":fuelpump:"},
- ':': {0: "NR"},
- ';': {0: "NSO", 1: "Park/Picnic"},
- '<': {0: "NTO", 1: "Advisory"},
- '=': {0: "NUO"},
- '>': {0: "NV#", 1: "Cars & Vehicles", 3: ":car:"},
- '?': {0: "NW", 1: "Info Kiosk"},
- '@': {0: "NX", 1: "Hurricane", 3: ":cyclone:"},
- 'A': {0: "AA#", 1: "Box DTMF & RFID"},
- 'B': {0: "AB"},
- 'C': {0: "AC", 1: "Coast Guard"},
- 'D': {0: "ADO", 1: "Depots"},
- 'E': {0: "AE", 1: "Smoke"},
- 'F': {0: "AF"},
- 'G': {0: "AG"},
- 'H': {0: "AHO", 1: "Haze"},
- 'I': {0: "AI", 1: "Rain Shower", 3: ":umbrella:"},
- 'J': {0: "AJ"},
- 'K': {0: "AK", 1: "Kenwood HT"},
- 'L': {0: "AL", 1: "Lighthouse"},
- 'M': {0: "AMO", 1: "MARS"},
- 'N': {0: "AN", 1: "Navigation Buoy"},
- 'O': {0: "AO", 1: "Rocket", 3: ":rocket:"},
- 'P': {0: "AP", 1: "Parking", 3: ":parking:"},
- 'Q': {0: "AQ", 1: "Quake"},
- 'R': {0: "ARO", 1: "Restaurant"},
- 'S': {0: "AS", 1: "Satellite/Pacsat", 3: ":rocket:"},
- 'T': {0: "AT", 1: "Thunderstorm"},
- 'U': {0: "AU", 1: "Sunny"},
- 'V': {0: "AV", 1: "VORTAC Nav Aid"},
- 'W': {0: "AW#", 1: "NWS Site"},
- 'X': {0: "AX", 1: "Pharmacy"},
- 'Y': {0: "AYO", 1: "Radios and devices"},
- 'Z': {0: "AZ"},
- '[': {0: "DSO", 1: "W. Cloud"},
- '\\': {0: "DTO", 1: "GPS"},
- ']': {0: "DU"},
- '^': {0: "DV#", 1: "Other Aircraft", 3: ":airplane:"},
- '_': {0: "DW#", 1: "WX Site"},
- '`': {0: "DX", 1: "Rain", 3: ":umbrella:"},
- 'a': {0: "SA#O"},
- 'b': {0: "SB"},
- 'c': {0: "SC#O", 1: "CD Triangle"},
- 'd': {0: "SD", 1: "DX Spot"},
- 'e': {0: "SE", 1: "Sleet"},
- 'f': {0: "SF", 1: "Funnel Cloud"},
- 'g': {0: "SG", 1: "Gale Flags"},
- 'h': {0: "SHO", 1: "Store or Hamfest"},
- 'i': {0: "SI#", 1: "Box / POI"},
- 'j': {0: "SJ", 1: "Work Zone"},
- 'k': {0: "SKO", 1: "Special Vehicle"},
- 'l': {0: "SL", 1: "Areas"},
- 'm': {0: "SM", 1: "Value Sign"},
- 'n': {0: "SN#", 1: "Triangle"},
- 'o': {0: "SO", 1: "Small Circle"},
- 'p': {0: "SP"},
- 'q': {0: "SQ"},
- 'r': {0: "SR", 1: "Restrooms"},
- 's': {0: "SS#", 1: "Ship/Boats", 3: ":speedboat:"},
- 't': {0: "ST", 1: "Tornado", 3: ":cyclone:"},
- 'u': {0: "SU#", 1: "Truck", 3: ":truck:"},
- 'v': {0: "SV#", 1: "Van", 3: ":minibus:"},
- 'w': {0: "SWO", 1: "Flooding"},
- 'x': {0: "SX", 1: "Wreck/Obstruction"},
- 'y': {0: "SY", 1: "Skywarn"},
- 'z': {0: "SZ#", 1: "Shelter"},
- '{': {0: "Q1"},
- '|': {0: "Q2", 1: "TNC Stream Switch"},
- '}': {0: "Q3"},
- '~': {0: "Q4", 1: "TNC Stream Switch"},
- }
-)
diff --git a/protocol/aprs/testdata/packets.txt b/protocol/aprs/testdata/packets.txt
new file mode 100644
index 0000000..98e1550
--- /dev/null
+++ b/protocol/aprs/testdata/packets.txt
@@ -0,0 +1,996 @@
+2026-03-02 15:49:27 CET: GW1523>APN000,TCPXX*,qAX,CWOP-4:@021449z5052.20N/00331.42E_126/009g023t061r000p000P000b10185h58L207eMB63
+2026-03-02 15:49:27 CET: EW6603>APRS,TCPXX*,qAX,CWOP-4:@021449z3815.25N/07827.60W_161/000g003t037r000p000P000h57b10357.DsVP
+2026-03-02 15:49:27 CET: IU6SSZ-15>APHBL3,TCPIP*,qAS,AD4NCO-10:@144927h4221.32N/01423.98E[000/000/D-APRS ADN Systems / DMR ID: 2226001
+2026-03-02 15:49:28 CET: G7UKK-10>APDW18,qAO,G7UKK-10:!5335.18N/00142.48W_202/007g011t054r000p000P000h79b10128144.800MHz Rx IGate and Weather Station near Huddersfield.
+2026-03-02 15:49:28 CET: EA4KM-B>APDG02,TCPIP*,qAC,EA4KM-BS:!4013.22ND00351.04W&RNG0001/A=000010 70cm Voice (D-Star) 438.62500MHz -7.6000MHz
+2026-03-02 15:49:28 CET: FW0217>APN000,TCPXX*,qAX,CWOP-5:@021449z4241.64N/07150.94W_.../...g...t017r000p000P000b10357h30eMB54
+2026-03-02 15:49:28 CET: W7DJK-5>APT314,WIDE1-1,WIDE2-1,qAR,N7XAO:/144930h4434.85N/12317.97W>071/000/A=000482/TinyTrak3|!.&D'R|!wa>!
+2026-03-02 15:49:28 CET: HS6LU-11>AESPT4,TCPIP*,qAC,T2HAKATA:!1647.81NP10114.32E>176/000/A=000488 Vin:12.40V. SAT:22 Topspeed:7kmh DX:8km Uptime:206:36
+2026-03-02 15:49:28 CET: BD7KHW-10>APLG01,TCPIP*,qAC,T2FRANCE:!2440.26N/11335.23ErLORA APRS 433.775 & BD7KHW <0xe9><0x9f><0xb6><0xe5><0x85><0xb3><0xe6><0xac><0xa2><0xe8><0xbf><0x8e><0xe4><0xbd><0xa0> BAT: 3.3
+2026-03-02 15:49:28 CET: CQ0XSM-11>APOTW1,CQ0XBF-3*,WIDE3-2,qAR,CT2IRK-11:> 13.2V Meteo Semideiro - Amsat-Po - 144.900 Mhz
+2026-03-02 15:49:27 CET: CR7BQX-8>APLRG1,TCPIP*,qAC,T2DENMARK:!L:0"[LAQ^a GLoRa APRS test|!.%_|
+2026-03-02 15:49:27 CET: AD4FM-3>SUPV2U,qAR,AK4ZX-10:`q0Ynpx>/`"80}mon 144.700 winlink_4
+2026-03-02 15:49:27 CET: YG1BYD>AESPG4,TCPIP*,qAC,T2TOKYO:@021449z0636.83S/10648.69E_000/000g000t082r...p...P...h81b09800L000 Wx Station YG1BYD v1.0 T=27<0xc2><0xb0> H=81% P=980
+2026-03-02 15:49:27 CET: KE0CGR>APU25N,TCPIP*,qAC,T2MCI:=3853.73N\09442.56WL {UIV32N}
+2026-03-02 15:49:27 CET: AD7MR-15>APDR17,WA7DRE-11,WIDE1*,WIDE2-1,qAR,WA7DRE:=4801.77N/11632.45WE APRSDroid, UV-PRO
+2026-03-02 15:49:27 CET: HB9TMR-9>HB9TMR-9,WIDE1-1,WIDE2-1,qAR,OE9XVI-6:!4714.23N/00927.68E[285/000/A=001605HB9TMR-9
+2026-03-02 15:49:27 CET: BH6-1>APE32A,WIDE1-1,qAR,BH6MKM-11:>https://github.com/nakhonthai/ESP32APRS_Audio
+2026-03-02 15:49:27 CET: WINLINK>APWL2K,TCPIP*,qAS,WLNK-1:;KD0IVV-10*020249z4103. NW09547. Wa145.090MHz Winlink Packet Gateway
+2026-03-02 15:49:27 CET: 9W2KHE-3>APLRG1,TCPIP*,qAC,T2FINLAND:!LMa'hhar=_ !G.../...g...t085h..b10127Bandar Dato' Onn LoRa iGate 433.400MHz
+2026-03-02 15:49:27 CET: N0KFB-11>T5PT3Q,WIDE1-1,qAR,W0ANA-15:`y*hm]R>/"6{}Out runnin' 'round / Mon 444.750,146.58,14].52
+2026-03-02 15:49:27 CET: KC7O-9>APT311,N6EX-4*,qAR,KELLER:!3409.44N/11809.07W>057/000/A=000931
+2026-03-02 15:49:27 CET: WH6CDU-N>APDG03,TCPIP*,qAC,WH6CDU-NS:!4015.00ND08604.20W&/A=0000002m MMDVM Voice (C4FM) 145.65000MHz +0.0000MHz, WH6CDU_Pi-Star_ND
+2026-03-02 15:49:27 CET: VE7KGV-7>TYQTUS,FROMME,WIDE1*,WIDE2-1,qAR,AF7DX-1:`4[koq/[/`"4h}_0
+2026-03-02 15:49:27 CET: SA6BXE-2>APMI06,TCPIP*,qAC,T2SWEDEN:T#171,184,000,000,000,000,00000000
+2026-03-02 15:49:27 CET: VE1YAR>APNU19,WIDE3-3,qAR,VE1GU-2:!4356.67NS06601.32W#PHG5460/Yarmouth,NS MARCAN UIDIGI YARC
+2026-03-02 15:49:28 CET: SR5SAN>APRX29,DL2-2,qAR,SR5ZOC-1:;438.837OC*111111z5211.14N/02246.90Er438.837MHz<0xc2><0xa0>-760<0xc2><0xa0>R60k<0xc2><0xa0>SR8DMR<0xc2><0xa0>Chotycze
+2026-03-02 15:49:28 CET: BG6STN-7>APRS,TCPIP*,qAC,T2VAN:@021449z3026.72N/11131.94EOPHG5920 <0xe4><0xb8><0xbb><0xe9><0xa1><0xb5>:https://www.bg6stn.top , <0xe8><0xbf><0x90><0xe8><0xa1><0x8c><0xe6><0x97><0xb6><0xe9><0x97><0xb4>181<0xe5><0xa4><0xa9>23<0xe6><0x97><0xb6>0<0xe5><0x88><0x86> <0xe9><0x80><0x9f><0xe5><0xba><0xa6>133.0km/h <0xe6><0x80><0xbb><0xe9><0x87><0x8c><0xe7><0xa8><0x8b>355187.6km
+2026-03-02 15:49:28 CET: OE3XVI-S>APDG01,TCPIP*,qAC,OE3XVI-GS:;OE3XVI B *021449z4756.28ND01549.12EaRNG0031 440 Voice 438.27500MHz -7.6000MHz
+2026-03-02 15:49:28 CET: DG5GSA-B>APLRG1,TCPIP*,qAC,T2UKRAINE:=4817.28NL00749.18E&Lora I-Gate an X30 Antenne + VV + Bandpass, Solar Powerd
+2026-03-02 15:49:28 CET: CW5988>APRS,TCPXX*,qAX,CWOP-5:@021449z3745.58N/12225.90W_276/004g009t056r000p007P007h87b10176L009.DsVP
+2026-03-02 15:49:28 CET: EL-F5LCT>RXTLM-1,TCPIP,qAR,F5LCT::EL-F5LCT :UNIT.RX Erlang,TX Erlang,RXcount/10m,TXcount/10m,none1,STxxxxxx,logic
+2026-03-02 15:49:28 CET: BD4KM-10>APET51,TCPIP*,qAC,T2FUKUOKA:!3730.66N/12203.05Erweihai igate 144.640MHz BR4IW 439.900 -5 88.5 8.5V
+2026-03-02 15:49:28 CET: EL-F5LCT>RXTLM-1,TCPIP,qAR,F5LCT:T#232,0.00,0.00,0,1,0.0,00000000,SimplexLogic
+2026-03-02 15:49:28 CET: EL-F5LCT>RXTLM-1,TCPIP,qAR,F5LCT:T#233,0.00,0.00,0,1,0.0,00000000,SimplexLogic2
+2026-03-02 15:49:28 CET: BI1NGG>APIN20,TCPIP*,qAC,T2YANTAI:!4024.48N/11728.79Er[QSO:439.600MHz -8.0MHz TSQ:100.0]
+2026-03-02 15:49:28 CET: AMITY>APRS,TCPXX*,qAX,CWOP-5:@021449z3024.00N/09106.00W_030/001g004t064r000p000P000h87b10215.DsVP
+2026-03-02 15:49:28 CET: EA1MNB-9>APLRT1,WIDE1-1,WIDE2-1,qAR,EC1AME-10:>https://github.com/richonguzman/LoRa_APRS_Tracker 2024.10.11
+2026-03-02 15:49:28 CET: JP1YJX-I>APIRP2,TCPIP*,qAC,JP1YJX-IS:!3524.64ND13924.17E&EBINA -> APRS
+2026-03-02 15:49:28 CET: CE4TQY-7>APLRT1,WIDE1-1,qAR,CE4TQY-10:=/_g:JRXTLM-1,TCPIP,qAR,CN8EAA:T#541,0.00,0.02,0,1,0.0,00000000,RepeaterLogic
+2026-03-02 15:49:33 CET: EW4266>APRS,TCPXX*,qAX,CWOP-4:@021449z4415.33N/07305.98W_202/003g008t010r000p000P000h51b10384L499.DsVP
+2026-03-02 15:49:33 CET: NX2I-11>APRX29,TCPIP*,qAC,T2RDU::NX2I-11 :PARM.Avg 10m,Avg 10m,RxPkts,IGateDropRx,TxPkts
+2026-03-02 15:49:34 CET: W1HS-11>APMI06,TCPIP*,qAC,T2NALA:@021449z4315.93NT07221.29W&PHG7480/RX/TX iGate Perry Mountain, Charlestown, NH (14.2V 25.4F)
+2026-03-02 15:49:34 CET: IW4DGS-S>APDG01,TCPIP*,qAC,IW4DGS-GS:;IW4DGS B *021449z4412.29NW01203.57EiRNG0001/A=000010 70cm Voice (D-Star) 430.00000MHz +0.0000MHz, APRS for ircDDBGateway
+2026-03-02 15:49:34 CET: IW4DGS-S>APDG01,qAS,IW4DGS:>Powered by WPSD (https://wpsd.radio)
+2026-03-02 15:49:34 CET: DW9097>APN000,TCPXX*,qAX,CWOP-5:@021449z3737.47N/01502.74E_272/002g007t056r000p000P000b10231h74L348eMB63
+2026-03-02 15:49:34 CET: IU2HUQ-S>APDG01,TCPIP*,qAC,IU2HUQ-GS:;IU2HUQ B *021449z4626.20ND01107.40EaRNG0001/A=000010 70cm Voice (D-Star) 433.65000MHz +0.0000MHz
+2026-03-02 15:49:34 CET: LW2HAH>APBM1D,LW2HAH,DMR*,qAR,LW2HAH:@144931h3237.96S/06241.52W[116/001APRS_DMR_LW2HAH-7_DV
+2026-03-02 15:49:34 CET: EI7JQ-14>APDR16,TCPIP*,qAC,T2CSNGRAD:=5323.87N/00802.87Wu020/050/A=000444 I monitor 145.500 and DMR
+2026-03-02 15:49:34 CET: PY2NET-5>APDR16,TCPIP*,qAC,T2LAUSITZ:=2258.77S/04704.69W(/A=002158 Robson 439.725 DStar Valinhos SP
+2026-03-02 15:49:34 CET: W4GCW-9>APT314,N1KSC-1*,WIDE1*,KM4ZYG-10*,WIDE2*,qAR,W4KBW:>Voice on 146.520
+2026-03-02 15:49:34 CET: KD1KE>APU25N,TCPIP*,qAC,T2PERTH:@021449z4428.85N/06920.58W_178/001g005t009r000p000P000h52b10347WX Station of KD1KE {UIV32N}
+2026-03-02 15:49:34 CET: EL-E71ACU>RXTLM-1,TCPIP,qAR,E71ACU::EL-E71ACU:UNIT.RX Erlang,TX Erlang,RXcount/10m,TXcount/10m,none1,STxxxxxx,logic
+2026-03-02 15:49:34 CET: EL-E71ACU>RXTLM-1,TCPIP,qAR,E71ACU:T#010,0.00,0.67,0,21,0.0,00000000,SimplexLogic
+2026-03-02 15:49:34 CET: FW3802>APN000,TCPXX*,qAX,CWOP-4:@021449z4732.40N/11710.20W_302/000g001t046r000p000P000b10322h70eMB63
+2026-03-02 15:49:34 CET: KG7JQP>APWW11,TCPIP*,qAC,T2PANAMA:>021449zDX: W7SWT-11 38.5mi 78<0xb0> 14:49 4851.28N 11108.41W
+2026-03-02 15:49:34 CET: HK4RAU-40>HK4DAP,TCPIP*,qAC,T2CAEAST:>Estacion Meterologica APRS con SCRIP Python, Raspberry Pi,ESP32 y Sensor BMP280 por HK4DAP
+2026-03-02 15:49:34 CET: HK4RAU-40>HK4DAP,TCPIP*,qAC,T2CAEAST:@021449z0728.01N/07651.04W_010/010g000t082r000p000h78b10100ESTACION DEL CLIMA APRS ZONA 4 RADIOAFICIONADOS UNIDOS 146.520 MHz en Simplex Fonia - Reporte del clima en Apartad<0xc3><0xb3>: nubes dispersas
+2026-03-02 15:49:34 CET: VA6AEA>APDW16,TCPIP*,qAC,T2CAEAST:!5304.71NR11408.68W&VA6AEA iGate
+2026-03-02 15:49:34 CET: EA5JMY-13>APRS,TCPIP*,qAC,T2UK:=3929.52N/00022.44W_.../...g...t065r...p...P...h54b10187
+2026-03-02 15:49:34 CET: EA5JMY-13>APRS,TCPIP*,qAC,T2UK:T#070,050,304,000,000,000,00000000
+2026-03-02 15:49:34 CET: WINLINK>APWL2K,TCPIP*,qAS,WLNK-1:;KD2DO-10 *020249z4306. NW07737. Wa145.030MHz Winlink Packet Gateway
+2026-03-02 15:49:34 CET: SM0TCZ-13>APMI01,TCPIP*,qAS,SM0TCZ:@021449z5914.43N/01757.80EI#WX3in1Plus
+2026-03-02 15:49:34 CET: DB0DB>APU25N,TCPIP*,qAC,T2POLNW:=4746.60N/00742.07EIigate Blauen {UIV32N}
+2026-03-02 15:49:34 CET: K4KJQ-13>APMI06,TCPIP*,qAS,K4KJQ:;146.76-KY*111111z3802.38N/08424.18Wr146.760MHz T67 -060 R12m
+2026-03-02 15:49:34 CET: YD3BHZ-7>APBTUV,YH3NPX-4*,WIDE2-1,qAR,YH3NPX-2:!0745.47S/11312.58E>208/001/A=0001295RH PRO JATIM-CLUB
+2026-03-02 15:49:34 CET: OH3KUN-9>APLT00,qAO,OH3ERV-L1:!6128.15N/02346.49E>174/019/A=000341
+2026-03-02 15:49:34 CET: IR2UDV>APBM1S,TCPIP*,qAS,BM2222:>https://brandmeister.network/?page=repeater&id=222055
+2026-03-02 15:49:34 CET: W9MID-10>GPS,qAR,W9RCA-10:MARC MEETING 8am 3rd Satur<0x7f><0x7f> at REMC Hq. Club Net Sun 7pm. [Unsupported packet format]
+2026-03-02 15:49:34 CET: WV8AR-10>APRX29,qAR,KG4DVE-10:;WV8AR-10 *111111z3824.36N/08154.06W#APRS Digi - Scott Depot, WV -WV8AR-
+2026-03-02 15:49:34 CET: ED1ZAK-3>UIDIGI,qAR,ED1ZBA-3:UIDIGI 1.9
+2026-03-02 15:49:34 CET: TB3DEU-13>APLRT1,WIDE1-1,qAR,TB3DEU-16:=/:1(lUmLgbD@Q
+2026-03-02 15:49:34 CET: G8DHE-1>APDR16,TCPIP*,qAC,T2KA:=5049.68N/00022.95W$097/000/A=000196 GP9
+2026-03-02 15:49:34 CET: EA5IHI-13>AESPG4,TCPIP*,qAC,T2BIO:>V.4.4b Rx:0 Digi:0 Tx:32 UpTime:00.28
+2026-03-02 15:49:34 CET: E21ZSS-S>APDG01,TCPIP*,qAC,E21ZSS-GS:;E21ZSS C *021449z1240.13ND10116.52EaRNG0001/A=000010 2m Voice (D-Star) 145.56250MHz +0.0000MHz
+2026-03-02 15:49:34 CET: EA3EW-N>APDG03,TCPIP*,qAC,EA3EW-NS:!4115.00ND00110.20E&/A=00000070cm MMDVM Voice (C4FM) 430.01250MHz +0.0000MHz, EA3EW_Pi-Star_ND
+2026-03-02 15:49:34 CET: KB8SCS-1>APMI06,WIDE2-2,qAR,KD9MAB-15:@021355z4024.51N/08448.21W#W9JCA Coffee and Donuts SAT'S 8am JAY CNTY FAIRGROUNDS SCOUT CABIN
+2026-03-02 15:49:34 CET: IR3UIB>APBM1S,TCPIP*,qAS,BM2222:@021449z4630.42N/01255.97ErPHG0000ARI RCE FVG 433.1250/431.5250 CC1
+2026-03-02 15:49:34 CET: DL8FMA-10>APLG01,TCPIP*,qAC,T2UKRAINE:=4759.79NL01200.42E&LoRa iGATE APRS 70cm 433.775 MHz, Info: github.com/lora-aprs/LoRa_APRS_iGate
+2026-03-02 15:49:34 CET: IZ1ZCT-15>APLRT1,WIDE2-2,qAO,IZ1RWC-15:=/82Q?PyYC>=1QARI La Spezia - sysop: Roberto - iz1zct@gmail.com|(%%;|
+2026-03-02 15:49:34 CET: IR3UIB>APBM1S,TCPIP*,qAS,BM2222:>https://brandmeister.network/?page=repeater&id=222056
+2026-03-02 15:49:34 CET: GW3184>APRS,TCPXX*,qAX,CWOP-4:@021450z4024.40N/08005.77W_085/000g004t025r000p000P000h62b10326.WFL
+2026-03-02 15:49:34 CET: LU2DKV-10>AESPG4,TCPIP*,qAC,T2SYDNEY::LU2DKV-10:EQNS.0,1,0,0,1,0,0,1,0,0,-1,0,0,1,0
+2026-03-02 15:49:34 CET: GW2602>APN000,TCPXX*,qAX,CWOP-7:@021449z3821.45N/12200.01W_186/002g005t056r000p000P000b10171h81eMB60
+2026-03-02 15:49:34 CET: DO0KL-S>APDG01,TCPIP*,qAC,DO0KL-GS:;DO0KL B *021449z5129.70NW00632.85EiRNG0019/A=000262 70cm Voice (D-Star) 439.13750MHz -7.6000MHz, APRS for ircDDBGateway
+2026-03-02 15:49:34 CET: DO0KL-S>APDG01,qAS,DO0KL:>Powered by WPSD (https://wpsd.radio)
+2026-03-02 15:49:34 CET: GW0DQW-10>APLRG1,TCPIP*,qAC,T2PRT:!L4D:7Mczua GLoRa APRS
+2026-03-02 15:49:34 CET: WA8LMF-42>APU25N,TCPIP*,qAC,WG3K-CA:>251749zAPRS-over-VARA Webservers http://WA8LMF.net/map
+2026-03-02 15:49:34 CET: DL1NUX>APFII0,TCPIP*,qAC,APRSFI:@144934h5014.07N/01058.85Eb086/006/A=000998Attila B37!w>o!
+2026-03-02 15:49:34 CET: TI0ARC-13>APRS,TCPIP*,qAC,FIRST:=0949.91N/08352.86W_.../...g...t070r...p...P...h69b10122
+2026-03-02 15:49:34 CET: TI0ARC-13>APRS,TCPIP*,qAC,FIRST:T#239,080,306,000,000,000,00000000
+2026-03-02 15:49:34 CET: IR0UJN>APBM1S,TCPIP*,qAS,BM2222:@021449z4144.79N/01239.02ErPHG0000Sono attivi i seguenti TG: 222059 773 Radio Group Roma/Latina (ex TG222773) SLOT2 e 222094 R.N.R.E. Protezione Civile (ex TG22211) SLOT1 430.5375/435.5375 CC1
+2026-03-02 15:49:34 CET: IR0UJN>APBM1S,TCPIP*,qAS,BM2222:>https://brandmeister.network/?page=repeater&id=222059
+2026-03-02 15:49:35 CET: GW5315>APRS,TCPXX*,qAX,AMBCWOP-1:@021449z3523.12N/08054.20W_005/005g010t048r000p000P000h64b10333L305AmbientCWOP.com
+2026-03-02 15:49:35 CET: F4HQI-10>APLRG1,TCPIP*,qAC,T2FRANCE:=L5zz_P2v`a !GLoRa APRS 73'
+2026-03-02 15:49:35 CET: IR0UEI>APBM1S,TCPIP*,qAS,BM2222:@021449z4226.04N/01235.84ErPHG0000IR0UEI - S. Pancrazio TR 431.4625/433.0625 CC1
+2026-03-02 15:49:35 CET: IR0UEI>APBM1S,TCPIP*,qAS,BM2222:>https://brandmeister.network/?page=repeater&id=222060
+2026-03-02 15:49:34 CET: ON0ABT-2>APMI03,WIDE2-2,qAR,ON1TG-10:=5106.91N200318.93E# DIGI Beernem - WOC vzw
+2026-03-02 15:49:34 CET: DO7AD>APU25N,TCPIP*,qAC,T2FINLAND: