├── .gitignore ├── address.go ├── aprs.go ├── aprs_test.go ├── aprsis └── aprsis.go ├── ax25 ├── decoder_test.go ├── encode_test.go ├── frames.go └── radio.sample ├── gate ├── .gitignore ├── gate.go ├── notifications.go ├── notify-sample.json ├── point_test.go ├── server.go └── web.go ├── logs ├── igate.log.gz ├── message.log.gz └── tnc.log.gz ├── messages.go ├── messages_test.go ├── position.go ├── position_test.go ├── samples ├── faptests.json └── large.log.bz2 ├── symbols.go └── type.go /.gitignore: -------------------------------------------------------------------------------- 1 | #* 2 | *.6 3 | *.a 4 | *~ 5 | 6.out 6 | _* 7 | /ax25/ax25 8 | /gate/notify.json 9 | .idea/ 10 | -------------------------------------------------------------------------------- /address.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // An Address for APRS (callsign with optional SSID) 9 | type Address struct { 10 | Call string 11 | SSID string 12 | } 13 | 14 | // The string representation of an address. 15 | func (a Address) String() string { 16 | rv := a.Call 17 | if a.SSID != "" { 18 | rv = fmt.Sprintf("%s-%s", a.Call, a.SSID) 19 | } 20 | return rv 21 | } 22 | 23 | // CallPass algorithm for APRS-IS 24 | func (a Address) CallPass() int16 { 25 | rv := int16(0x73e2) 26 | for i := 0; i < len(a.Call); { 27 | rv ^= int16(a.Call[i]) << 8 28 | if i+1 < len(a.Call) { 29 | rv ^= int16(a.Call[i+1]) 30 | } 31 | i += 2 32 | } 33 | return rv & 0x7fff 34 | } 35 | 36 | func parseAddresses(addrs []string) []Address { 37 | var rv []Address 38 | 39 | for _, s := range addrs { 40 | rv = append(rv, AddressFromString(s)) 41 | } 42 | 43 | return rv 44 | } 45 | 46 | // AddressFromString builds an Addrss object from a string. 47 | func AddressFromString(s string) Address { 48 | parts := strings.Split(s, "-") 49 | rv := Address{Call: parts[0]} 50 | if len(parts) > 1 { 51 | rv.SSID = parts[1] 52 | } 53 | return rv 54 | } 55 | -------------------------------------------------------------------------------- /aprs.go: -------------------------------------------------------------------------------- 1 | // Package aprs provides an Amateur Packet Radio Service messaging interface. 2 | package aprs 3 | 4 | import ( 5 | "bytes" 6 | "strings" 7 | ) 8 | 9 | // Info represents the information payload of an APRS packet. 10 | type Info string 11 | 12 | // Frame represents a complete, abstract, APRS frame. 13 | type Frame struct { 14 | Original string 15 | Source Address 16 | Dest Address 17 | Path []Address 18 | Body Info 19 | } 20 | 21 | // IsValid is true if a message was correctly parsed. 22 | func (d Frame) IsValid() bool { 23 | return d.Original != "" 24 | } 25 | 26 | // Type of the message. 27 | func (b Info) Type() PacketType { 28 | t := PacketType(0) 29 | if len(b) > 0 { 30 | t = PacketType(b[0]) 31 | } 32 | return t 33 | } 34 | 35 | // ParseFrame parses an APRS string into an Frame struct. 36 | func ParseFrame(i string) Frame { 37 | parts := strings.SplitN(i, ":", 2) 38 | 39 | if len(parts) != 2 { 40 | return Frame{} 41 | } 42 | srcparts := strings.SplitN(parts[0], ">", 2) 43 | if len(srcparts) < 2 { 44 | return Frame{} 45 | } 46 | pathparts := strings.Split(srcparts[1], ",") 47 | 48 | return Frame{Original: i, 49 | Source: AddressFromString(srcparts[0]), 50 | Dest: AddressFromString(pathparts[0]), 51 | Path: parseAddresses(pathparts[1:]), 52 | Body: Info(parts[1])} 53 | } 54 | 55 | // String forms an Frame back into its proper wire format. 56 | func (d Frame) String() string { 57 | b := bytes.NewBufferString(d.Source.String()) 58 | b.WriteByte('>') 59 | b.WriteString(d.Dest.String()) 60 | for _, p := range d.Path { 61 | b.WriteByte(',') 62 | b.WriteString(p.String()) 63 | } 64 | b.WriteByte(':') 65 | b.WriteString(string(d.Body)) 66 | return b.String() 67 | } 68 | -------------------------------------------------------------------------------- /aprs_test.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "bufio" 5 | "compress/bzip2" 6 | "encoding/json" 7 | "io" 8 | "math" 9 | "os" 10 | "reflect" 11 | "testing" 12 | ) 13 | 14 | const ( 15 | christmasMsg = "KG6HWF>APX200,WIDE1-1,WIDE2-1:=3722.1 N/12159.1 W-Merry Christmas!" 16 | ) 17 | 18 | const SAMPLE2 = `K7FED-1>APNX01,qAR,W6MSU-7:!3739.12N112132.05W#PHG5750 W1, K7FED FILL-IN LLNL S300` 19 | 20 | var samples = []struct { 21 | src string 22 | expected Position 23 | }{ 24 | {`K6LRG-C>APJI23,WIDE1-1,WIDE2-1:!3729.98ND12152.33W&RNG0060 2m Voice 145.070 +1.495 Mhz`, 25 | Position{37.49966666666667, -121.87216666666667, 0, Velocity{}, Symbol{'D', '&'}}}, 26 | {`K7FED-1>APNX01,qAR,W6MSU-7:!3739.12N112132.05W#PHG5750 W1, K7FED FILL-IN LLNL S300`, 27 | Position{37.652, -121.534167, 0, Velocity{}, Symbol{'1', '#'}}}, 28 | {`WINLINK>APWL2K,TCPIP*,qAC,T2LAX:;KE6AFE-10*160752z3658. NW12202. Wa144.910MHz 1200 R6m Public Winlink Gateway`, 29 | Position{36.975, -122.0416666, 2, Velocity{}, Symbol{'W', 'a'}}}, 30 | {`KE6AFE-13>APKH2Z,TCPIP*,qAC,CORE-2:;VP@CM86XX*162000z3658.94N/12200.86W? KE6AFE-13 8800`, 31 | Position{36.9823333, -122.014333, 0, Velocity{}, Symbol{'/', '?'}}}, 32 | } 33 | 34 | func assert(t *testing.T, name string, got interface{}, expected interface{}) { 35 | if got != expected { 36 | t.Fatalf("Expected %v for %v, got %v", expected, name, got) 37 | } 38 | // t.Logf("Looks like %s was %s", name, expected) 39 | } 40 | 41 | func assertEpsilon(t *testing.T, field string, expected, got float64) { 42 | if math.Abs(got-expected) > 0.0001 { 43 | t.Fatalf("Expected %v for %v, got %v -- off by %v", 44 | expected, field, got, math.Abs(got-expected)) 45 | } 46 | } 47 | 48 | func TestAddressConversion(t *testing.T) { 49 | testaddrs := []struct { 50 | Src string 51 | Exp Address 52 | }{ 53 | {"KG6HWF", Address{"KG6HWF", ""}}, 54 | {"KG6HWF-9", Address{"KG6HWF", "9"}}, 55 | {"KG6HWF-C", Address{"KG6HWF", "C"}}, 56 | {"KG6HWF-FLY", Address{"KG6HWF", "FLY"}}, 57 | } 58 | 59 | for _, ta := range testaddrs { 60 | a := AddressFromString(ta.Src) 61 | if !reflect.DeepEqual(a, ta.Exp) { 62 | t.Fatalf("Expected %v for %v, got %v", ta.Exp, ta.Src, a) 63 | } 64 | 65 | if ta.Exp.String() != ta.Src { 66 | t.Fatalf("Expected to string %v as %s, got %s", 67 | ta.Exp, ta.Src, ta.Exp.String()) 68 | } 69 | } 70 | } 71 | 72 | func TestCallPass(t *testing.T) { 73 | testaddrs := []struct { 74 | Call Address 75 | Exp int16 76 | }{ 77 | {AddressFromString("KG6HWF-9"), 22955}, 78 | {AddressFromString("KG6HWF"), 22955}, 79 | {AddressFromString("KE6AFE-13"), 18595}, 80 | {AddressFromString("K6MGD"), 12691}, 81 | } 82 | 83 | for _, c := range testaddrs { 84 | if c.Call.CallPass() != c.Exp { 85 | t.Errorf("Expected %v for %v, got %v", c.Exp, c.Call, c.Call.CallPass()) 86 | } 87 | } 88 | } 89 | 90 | func TestAPRS(t *testing.T) { 91 | v := ParseFrame(christmasMsg) 92 | assert(t, "Source", v.Source.String(), "KG6HWF") 93 | assert(t, "Dest", v.Dest.String(), "APX200") 94 | assert(t, "len(Path)", len(v.Path), 2) 95 | assert(t, "Path[0]", v.Path[0].String(), "WIDE1-1") 96 | assert(t, "Path[1]", v.Path[1].String(), "WIDE2-1") 97 | assert(t, "Body", string(v.Body), "=3722.1 N/12159.1 W-Merry Christmas!") 98 | 99 | pos, err := v.Body.Position() 100 | if err != nil { 101 | t.Fatalf("Couldn't parse body position: %v", err) 102 | } 103 | 104 | assertEpsilon(t, "lat", 37.3691667, pos.Lat) 105 | assertEpsilon(t, "lon", -121.985833, pos.Lon) 106 | assert(t, "ambiguity", 1, pos.Ambiguity) 107 | assert(t, "table", byte('/'), pos.Symbol.Table) 108 | assert(t, "symbol", byte('-'), pos.Symbol.Symbol) 109 | 110 | assert(t, "String()", v.String(), christmasMsg) 111 | } 112 | 113 | func TestInvalid(t *testing.T) { 114 | v := ParseFrame("Invalid") 115 | if v.IsValid() { 116 | t.Fatalf("Expected invalid data out of the parse") 117 | } 118 | assert(t, "Source", v.Source.String(), "") 119 | assert(t, "Dest", v.Dest.String(), "") 120 | assert(t, "len(Path)", len(v.Path), 0) 121 | assert(t, "Body", string(v.Body), "") 122 | 123 | v = ParseFrame("Invalid:Thing") 124 | if v.IsValid() { 125 | t.Fatalf("Expected invalid data out of the parse") 126 | } 127 | assert(t, "Source", v.Source.String(), "") 128 | assert(t, "Dest", v.Dest.String(), "") 129 | assert(t, "len(Path)", len(v.Path), 0) 130 | assert(t, "Body", string(v.Body), "") 131 | } 132 | 133 | func TestSamples(t *testing.T) { 134 | for _, s := range samples { 135 | v := ParseFrame(s.src) 136 | pos, err := v.Body.Position() 137 | if err != nil { 138 | t.Fatalf("Error getting position from %v: %v", s.src, err) 139 | } 140 | assert(t, "ambiguity", s.expected.Ambiguity, pos.Ambiguity) 141 | assert(t, "table", s.expected.Symbol.Table, pos.Symbol.Table) 142 | assert(t, "symbol", s.expected.Symbol.Symbol, pos.Symbol.Symbol) 143 | assert(t, "course", s.expected.Velocity.Course, pos.Velocity.Course) 144 | assert(t, "speed", s.expected.Velocity.Speed, pos.Velocity.Speed) 145 | assertEpsilon(t, "lat", s.expected.Lat, pos.Lat) 146 | assertEpsilon(t, "lon", s.expected.Lon, pos.Lon) 147 | } 148 | } 149 | 150 | type SampleDoc struct { 151 | Src string `json:"src"` 152 | Result map[string]interface{} `Json:"result"` 153 | Failed int `json:"failed"` 154 | Misunderstood bool 155 | } 156 | 157 | func assertLatLon(t *testing.T, pos Position, doc SampleDoc) { 158 | slat, haslat := doc.Result["latitude"].(float64) 159 | slon, haslon := doc.Result["longitude"].(float64) 160 | if !(haslat && haslon) { 161 | return 162 | } 163 | if math.Abs(pos.Lat-slat) > 0.001 || math.Abs(pos.Lon-slon) > 0.001 { 164 | t.Fatalf("Error parsing lat/lon from %v, got %v; expected %v,%v", 165 | doc.Src, pos, slat, slon) 166 | } 167 | tbl := doc.Result["symboltable"].(string)[0] 168 | if pos.Symbol.Table != tbl { 169 | t.Fatalf("Expected symbol table %v, got %v for %v", 170 | tbl, pos.Symbol.Table, doc.Src) 171 | } 172 | symbol := doc.Result["symbolcode"].(string)[0] 173 | if pos.Symbol.Symbol != symbol { 174 | t.Fatalf("Expected symbol %v, got %v for %v", 175 | symbol, pos.Symbol.Symbol, doc.Src) 176 | } 177 | course, _ := doc.Result["course"].(float64) 178 | assertEpsilon(t, "course of "+doc.Src, course, pos.Velocity.Course) 179 | speed, _ := doc.Result["speed"].(float64) 180 | assertEpsilon(t, "speed of "+doc.Src, speed, pos.Velocity.Speed) 181 | } 182 | 183 | func negAssertLatLon(t *testing.T, pos Position, doc SampleDoc) { 184 | slat, haslat := doc.Result["latitude"].(float64) 185 | slon, haslon := doc.Result["longitude"].(float64) 186 | if !(haslat && haslon) { 187 | return 188 | } 189 | if !(math.Abs(pos.Lat-slat) > 0.001 || math.Abs(pos.Lon-slon) > 0.001) { 190 | t.Fatalf("Expected to fail parsing lat/lon from %v, got %v; expected %v,%v", 191 | doc.Src, pos, slat, slon) 192 | } 193 | } 194 | 195 | func assertWX(t *testing.T, body Info, want interface{}) { 196 | t.Logf("Looking for wx data from %v, want %v", body, want) 197 | } 198 | 199 | func TestFAP(t *testing.T) { 200 | expSuccess := 29 201 | 202 | var samples []SampleDoc 203 | r, err := os.Open("samples/faptests.json") 204 | if err != nil { 205 | t.Fatalf("Error opening sample.json") 206 | } 207 | defer r.Close() 208 | err = json.NewDecoder(r).Decode(&samples) 209 | if err != nil { 210 | t.Fatalf("Error reading JSON: %v", err) 211 | } 212 | t.Logf("Found %d messages", len(samples)) 213 | 214 | positions := 0 215 | misunderstood := 0 216 | 217 | for _, sample := range samples { 218 | if sample.Failed != 1 { 219 | v := ParseFrame(sample.Src) 220 | assert(t, "Source", v.Source.String(), sample.Result["srccallsign"]) 221 | assert(t, "Dest", v.Dest.String(), sample.Result["dstcallsign"]) 222 | assert(t, "Body", string(v.Body), sample.Result["body"]) 223 | 224 | if sample.Misunderstood { 225 | misunderstood++ 226 | pos, err := v.Body.Position() 227 | if err == nil { 228 | negAssertLatLon(t, pos, sample) 229 | } 230 | t.Logf("Misunderstood: %s", sample.Src) 231 | } else { 232 | pos, err := v.Body.Position() 233 | if err == nil { 234 | assertLatLon(t, pos, sample) 235 | positions++ 236 | } 237 | if wx, ok := sample.Result["wx"]; ok { 238 | assertWX(t, v.Body, wx) 239 | } 240 | } 241 | } 242 | } 243 | 244 | if positions != expSuccess { 245 | t.Fatalf("Expected to pass at %v position tests, got %v", 246 | expSuccess, positions) 247 | } 248 | 249 | t.Logf("Found %v positions", positions) 250 | t.Logf("Misunderstood %v", misunderstood) 251 | } 252 | 253 | func getSampleLines(path string) []string { 254 | file, err := os.Open(path) 255 | if err != nil { 256 | panic("Could not open sample file: " + err.Error()) 257 | } 258 | defer file.Close() 259 | 260 | bz := bzip2.NewReader(file) 261 | rv := make([]string, 0, 250000) 262 | 263 | bio := bufio.NewReader(bz) 264 | bytesread := int64(0) 265 | done := false 266 | 267 | for !done { 268 | line, err := bio.ReadString('\n') 269 | switch err { 270 | case nil: 271 | rv = append(rv, line) 272 | bytesread += int64(len(line)) 273 | case io.EOF: 274 | done = true 275 | default: 276 | panic("Could not load samples: " + err.Error()) 277 | } 278 | } 279 | 280 | return rv 281 | } 282 | 283 | var largeSample []string 284 | 285 | func loadLargeSample(b *testing.B) { 286 | if largeSample == nil { 287 | largeSample = getSampleLines("samples/large.log.bz2") 288 | b.ResetTimer() 289 | } 290 | } 291 | 292 | func BenchmarkMessages(b *testing.B) { 293 | loadLargeSample(b) 294 | var read int64 295 | for i := 0; i < b.N; i++ { 296 | src := largeSample[i%len(largeSample)] 297 | ParseFrame(src) 298 | read += int64(len(src)) 299 | } 300 | b.SetBytes(read / int64(b.N)) 301 | } 302 | 303 | func BenchmarkPositionsFromLog(b *testing.B) { 304 | loadLargeSample(b) 305 | var read int64 306 | for i := 0; i < b.N; i++ { 307 | src := largeSample[i%len(largeSample)] 308 | msg := ParseFrame(src) 309 | msg.Body.Position() 310 | read += int64(len(src)) 311 | } 312 | b.SetBytes(read / int64(b.N)) 313 | } 314 | -------------------------------------------------------------------------------- /aprsis/aprsis.go: -------------------------------------------------------------------------------- 1 | // Package aprsis provides an interface to APRS-IS service. 2 | package aprsis 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/textproto" 10 | 11 | "github.com/dustin/go-aprs" 12 | ) 13 | 14 | var errEmptyMsg = errors.New("empty message") 15 | var errInvalidMsg = errors.New("invalid message") 16 | 17 | // An APRSIS connection. 18 | type APRSIS struct { 19 | conn *textproto.Conn 20 | rawLog io.Writer 21 | infoHandler InfoHandler 22 | } 23 | 24 | // InfoHandler is a handler for incoming info messages. 25 | type InfoHandler interface { 26 | Info(msg string) 27 | } 28 | 29 | type dumbInfoHandlerT struct{} 30 | 31 | func (d dumbInfoHandlerT) Info(msg string) { 32 | } 33 | 34 | var dumbInfoHandler dumbInfoHandlerT 35 | 36 | // Next returns the next APRS message from this connection. 37 | func (a *APRSIS) Next() (rv aprs.Frame, err error) { 38 | var line string 39 | for err == nil || err == errEmptyMsg { 40 | line, err = a.conn.ReadLine() 41 | if err != nil { 42 | return 43 | } 44 | 45 | fmt.Fprintf(a.rawLog, "%s\n", line) 46 | 47 | if len(line) > 0 && line[0] == '#' { 48 | a.infoHandler.Info(line) 49 | } else if len(line) > 0 { 50 | rv = aprs.ParseFrame(line) 51 | if !rv.IsValid() { 52 | err = errInvalidMsg 53 | } 54 | return rv, err 55 | } 56 | } 57 | 58 | return rv, errEmptyMsg 59 | } 60 | 61 | // SetRawLog sets a writer that will receive all raw APRS-IS messages. 62 | func (a *APRSIS) SetRawLog(to io.Writer) { 63 | a.rawLog = to 64 | } 65 | 66 | // SetInfoHandler set a handler for APRS-IS Info messages. 67 | func (a *APRSIS) SetInfoHandler(to InfoHandler) { 68 | a.infoHandler = to 69 | } 70 | 71 | // Dial an APRS-IS service. 72 | func Dial(prot, addr string) (rv *APRSIS, err error) { 73 | var conn *textproto.Conn 74 | conn, err = textproto.Dial(prot, addr) 75 | if err != nil { 76 | return 77 | } 78 | 79 | return &APRSIS{conn: conn, 80 | rawLog: ioutil.Discard, 81 | infoHandler: dumbInfoHandler, 82 | }, nil 83 | } 84 | 85 | // Close disconnects from the underlying textproto conn. 86 | func (a *APRSIS) Close() error { 87 | return a.conn.Close() 88 | } 89 | 90 | // Send raw APRS packet using underlying textproto conn. 91 | func (a *APRSIS) SendRawPacket(format string, args ...interface{}) error { 92 | return a.conn.PrintfLine(format, args...) 93 | } 94 | 95 | // Auth authenticates and optionally set a filter. 96 | func (a *APRSIS) Auth(user, pass, filter string) error { 97 | if filter != "" { 98 | filter = fmt.Sprintf(" filter %s", filter) 99 | } 100 | 101 | return a.SendRawPacket("user %s pass %s vers goaprs 0.1%s", 102 | user, pass, filter) 103 | } 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /ax25/decoder_test.go: -------------------------------------------------------------------------------- 1 | package ax25 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/dustin/go-aprs" 10 | ) 11 | 12 | func TestUnreasonablySmall(t *testing.T) { 13 | for i := 0; i < reasonableSize+1; i++ { 14 | a, err := decodeMessage(make([]byte, i)) 15 | if err != errShortMsg { 16 | t.Errorf("expected shortMessage error at %v, got %v/%v", 17 | i, a, err) 18 | } 19 | } 20 | } 21 | 22 | func TestTruncated(t *testing.T) { 23 | data := make([]byte, 20) 24 | a, err := decodeMessage(data) 25 | if err != errTruncatedMsg { 26 | t.Fatalf("Expected truncated message, got %v/%v", a, err) 27 | } 28 | } 29 | 30 | func TestCapture(t *testing.T) { 31 | f, err := os.Open("radio.sample") 32 | if err != nil { 33 | t.Fatalf("Error opening sample file: %v", err) 34 | } 35 | defer f.Close() 36 | 37 | expected := []aprs.Frame{ 38 | aprs.Frame{Source: aprs.Address{Call: "N6WKZ", SSID: "3"}, 39 | Dest: aprs.Address{Call: "APU25N", SSID: "0"}, 40 | Path: []aprs.Address{aprs.Address{Call: "WR6ABD", SSID: "0"}}, 41 | Body: "=3746.42N112226.00W# {UIV32N}\r"}, 42 | aprs.Frame{Source: aprs.Address{Call: "W1EJ", SSID: "10"}, 43 | Dest: aprs.Address{Call: "APT311", SSID: "0"}, 44 | Path: []aprs.Address{aprs.Address{Call: "WB6TMS", SSID: "5"}, 45 | aprs.Address{Call: "N6ZX", SSID: "3"}, 46 | aprs.Address{Call: "WIDE2", SSID: "0"}}, 47 | Body: "/210725z3814.29N/12236.93W>275/000/A=000013/ED J SAG"}, 48 | aprs.Frame{Source: aprs.Address{Call: "WR6ABD", SSID: "0"}, 49 | Dest: aprs.Address{Call: "APN382", SSID: "0"}, 50 | Path: []aprs.Address{}, 51 | Body: "!3706.66NS12150.69W#PHG5730 W1,NCAn Loma Prieta LPRC.net A=003980\r"}, 52 | aprs.Frame{Source: aprs.Address{Call: "N6ACK", SSID: "1"}, 53 | Dest: aprs.Address{Call: "APRS", SSID: "0"}, 54 | Path: []aprs.Address{}, 55 | Body: "}WR6ABD>APN382,TCPIP*,N6ACK-1*:!3706.66NS12150.69W#PHG5730 W1,NCAn Loma Prieta LPRC.net A=003980"}, 56 | aprs.Frame{Source: aprs.Address{Call: "N6ACK", SSID: "1"}, 57 | Dest: aprs.Address{Call: "APRS", SSID: "0"}, 58 | Path: []aprs.Address{}, 59 | Body: "}AC6SL-4>APD225,TCPIP*,N6ACK-1*:!3707.94NI12207.23W& receive-only-aprsd"}, 60 | aprs.Frame{Source: aprs.Address{Call: "CARSON", SSID: "0"}, 61 | Dest: aprs.Address{Call: "APN391", SSID: "0"}, 62 | Path: []aprs.Address{aprs.Address{Call: "ECHO", SSID: "0"}, 63 | aprs.Address{Call: "N6ZX", SSID: "3"}, 64 | aprs.Address{Call: "WIDE2", SSID: "0"}}, 65 | Body: "!3841.68N111959.36W#PHG7636/NCAn,TEMPn/WG6D/Carson Pass, CA/A=008573\r"}, 66 | aprs.Frame{Source: aprs.Address{Call: "KE6KYI", SSID: "0"}, 67 | Dest: aprs.Address{Call: "APU25N", SSID: "0"}, 68 | Path: []aprs.Address{aprs.Address{Call: "K6TUO", SSID: "3"}, 69 | aprs.Address{Call: "N6ZX", SSID: "3"}, 70 | aprs.Address{Call: "WIDE2", SSID: "0"}}, 71 | Body: "@210726z3751.53N/12012.83W_213/000g000t063r000p000P000h45b10096APRS/CWOP Weather\r"}, 72 | aprs.Frame{Source: aprs.Address{Call: "N6ACK", SSID: "1"}, 73 | Dest: aprs.Address{Call: "APRS", SSID: "0"}, 74 | Path: []aprs.Address{}, 75 | Body: "}N6VIG-9>SW4QTY,TCPIP*,N6ACK-1*:`1Q\x1el ?>\\\"4m}"}, 76 | aprs.Frame{Source: aprs.Address{Call: "KG6ZLQ", SSID: "12"}, 77 | Dest: aprs.Address{Call: "3X5SRR", SSID: "0"}, 78 | Path: []aprs.Address{aprs.Address{Call: "ECHO", SSID: "0"}, 79 | aprs.Address{Call: "WIDE1", SSID: "0"}, 80 | aprs.Address{Call: "N6ZX", SSID: "3"}, 81 | aprs.Address{Call: "WIDE2", SSID: "0"}}, 82 | Body: "`0Z)l\"{j/\"IN}"}, 83 | aprs.Frame{Source: aprs.Address{Call: "N6ACK", SSID: "1"}, 84 | Dest: aprs.Address{Call: "APRS", SSID: "0"}, 85 | Path: []aprs.Address{}, 86 | Body: "}WA6BAY-1>APRS,TCPIP*,N6ACK-1*:!!0000008101F905B0276B02E803E8----00AC001A00000000"}, 87 | aprs.Frame{Source: aprs.Address{Call: "W6SIG", SSID: "0"}, 88 | Dest: aprs.Address{Call: "APS228", SSID: "0"}, 89 | Path: []aprs.Address{aprs.Address{Call: "W6CX", SSID: "3"}}, 90 | Body: "=3834.22N/12118.36WoPHG33D0 CalEMA-Mather\r"}, 91 | aprs.Frame{Source: aprs.Address{Call: "KI6ASH", SSID: "0"}, 92 | Dest: aprs.Address{Call: "S7SXWV", SSID: "0"}, 93 | Path: []aprs.Address{aprs.Address{Call: "WA6TOW", SSID: "2"}, 94 | aprs.Address{Call: "W6CX", SSID: "3"}, 95 | aprs.Address{Call: "WIDE2", SSID: "0"}}, 96 | Body: "`24gl \x1c>/'\"3u}MT-RTG|%V%`'n|!wwU!|3"}} 97 | 98 | got := []aprs.Frame{} 99 | 100 | d := NewDecoder(f) 101 | for err == nil { 102 | var m aprs.Frame 103 | m, err = d.Next() 104 | if err == nil { 105 | got = append(got, m) 106 | } 107 | } 108 | if err != io.EOF { 109 | t.Fatalf("Error reading stream: %v", err) 110 | } 111 | 112 | if !reflect.DeepEqual(expected, got) { 113 | t.Fatalf("Expected:\n%#v\nGot:\n%#v", expected, got) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ax25/encode_test.go: -------------------------------------------------------------------------------- 1 | package ax25 2 | 3 | import ( 4 | "encoding/hex" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/dustin/go-aprs" 9 | ) 10 | 11 | const ( 12 | christmasMsg = "KG6HWF>APX200,WIDE1-1,WIDE2-1:=3722.1 N/12159.1 W-Merry Christmas!" 13 | ) 14 | 15 | func TestKISS(t *testing.T) { 16 | v := aprs.ParseFrame(christmasMsg) 17 | bc := EncodeAPRSCommand(v) 18 | t.Logf("Command:\n" + hex.Dump(bc)) 19 | 20 | br := EncodeAPRSResponse(v) 21 | t.Logf("Response:\n" + hex.Dump(br)) 22 | } 23 | 24 | func TestAddressConversion(t *testing.T) { 25 | testaddrs := []struct { 26 | Src string 27 | AX25Cmd []byte 28 | AX25Res []byte 29 | }{ 30 | {"KG6HWF", 31 | []byte{0x96, 0x8e, 0x6c, 0x90, 0xae, 0x8c, 0xe0}, 32 | []byte{0x96, 0x8e, 0x6c, 0x90, 0xae, 0x8c, 0x60}}, 33 | {"KG6HWF-9", 34 | []byte{0x96, 0x8e, 0x6c, 0x90, 0xae, 0x8c, 0xf2}, 35 | []byte{0x96, 0x8e, 0x6c, 0x90, 0xae, 0x8c, 0x72}}, 36 | } 37 | 38 | for _, ta := range testaddrs { 39 | a := aprs.AddressFromString(ta.Src) 40 | a25c := addressEncode(a, setSSIDMask) 41 | if !reflect.DeepEqual(a25c, ta.AX25Cmd) { 42 | t.Fatalf("Expected %v for AX25d %v, got %v", 43 | ta.AX25Cmd, ta.Src, a25c) 44 | } 45 | a25r := addressEncode(a, clearSSIDMask) 46 | if !reflect.DeepEqual(a25r, ta.AX25Res) { 47 | t.Fatalf("Expected %v for AX25d %v, got %v", 48 | ta.AX25Res, ta.Src, a25r) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ax25/frames.go: -------------------------------------------------------------------------------- 1 | // Package ax25 provides AX.25 encoding and decoding lib. 2 | package ax25 3 | 4 | import ( 5 | "bufio" 6 | "bytes" 7 | "errors" 8 | "io" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/dustin/go-aprs" 13 | ) 14 | 15 | const reasonableSize = 14 16 | 17 | var errShortMsg = errors.New("short message") 18 | var errTruncatedMsg = errors.New("truncated message") 19 | 20 | var setSSIDMask = byte(0x70 << 1) 21 | var clearSSIDMask = byte(0x30 << 1) 22 | 23 | func parseAddr(in []byte) aprs.Address { 24 | out := make([]byte, len(in)) 25 | for i, b := range in { 26 | out[i] = b >> 1 27 | } 28 | rv := aprs.Address{ 29 | Call: strings.TrimSpace(string(out[:len(out)-1])), 30 | SSID: strconv.Itoa(int((out[len(out)-1]) & 0xf)), 31 | } 32 | return rv 33 | } 34 | 35 | func decodeMessage(frame []byte) (rv aprs.Frame, err error) { 36 | if len(frame) < reasonableSize+1 { 37 | err = errShortMsg 38 | return 39 | } 40 | 41 | frame = frame[:len(frame)-1] 42 | 43 | rv.Source = parseAddr(frame[8:15]) 44 | rv.Dest = parseAddr(frame[1:8]) 45 | 46 | rv.Path = []aprs.Address{} 47 | 48 | frame = frame[15:] 49 | for len(frame) > 7 && frame[0] != 3 { 50 | rv.Path = append(rv.Path, parseAddr(frame[:7])) 51 | frame = frame[7:] 52 | } 53 | 54 | if len(frame) < 2 || frame[0] != 3 || frame[1] != 0xf0 { 55 | err = errTruncatedMsg 56 | return 57 | } 58 | 59 | rv.Body = aprs.Info(string(frame[2:])) 60 | 61 | return 62 | } 63 | 64 | // Decoder is an AX.25 message decoder. 65 | type Decoder struct { 66 | r *bufio.Reader 67 | } 68 | 69 | // Next gets the next message. 70 | func (d *Decoder) Next() (aprs.Frame, error) { 71 | frame := []byte{} 72 | var err error 73 | for len(frame) < reasonableSize { 74 | frame, err = d.r.ReadBytes(byte(0xc0)) 75 | if err != nil { 76 | return aprs.Frame{}, err 77 | } 78 | } 79 | return decodeMessage(frame) 80 | } 81 | 82 | // NewDecoder gets a new decoder over this reader. 83 | func NewDecoder(r io.Reader) *Decoder { 84 | return &Decoder{bufio.NewReader(r)} 85 | } 86 | 87 | func addressEncode(a aprs.Address, ssidMask byte) []byte { 88 | rv := make([]byte, 7) 89 | for i := 0; i < len(rv); i++ { 90 | rv[i] = ' ' 91 | } 92 | for i, c := range a.Call { 93 | rv[i] = byte(c) << 1 94 | } 95 | i, err := strconv.Atoi(a.SSID) 96 | if err != nil { 97 | i = 0 98 | } 99 | rv[6] = ssidMask | (byte(i) << 1) 100 | return rv 101 | } 102 | 103 | func toAX25(m aprs.Frame, smask, dmask byte) []byte { 104 | b := &bytes.Buffer{} 105 | b.Write(addressEncode(m.Dest, dmask)) 106 | mask := smask 107 | if len(m.Path) == 0 { 108 | mask |= 1 109 | } 110 | b.Write(addressEncode(m.Source, smask)) 111 | for i, p := range m.Path { 112 | mask = clearSSIDMask 113 | if i == len(m.Path)-1 { 114 | mask |= 1 115 | } 116 | b.Write(addressEncode(p, mask)) 117 | } 118 | b.Write([]byte{3, 0xf0}) 119 | b.Write([]byte(m.Body)) 120 | return b.Bytes() 121 | } 122 | 123 | // EncodeAPRSCommand encodes an APRS command to an AX.25 frame. 124 | func EncodeAPRSCommand(m aprs.Frame) []byte { 125 | return toAX25(m, setSSIDMask, clearSSIDMask) 126 | } 127 | 128 | // EncodeAPRSResponse encodes an APRS response to an AX.25 frame. 129 | func EncodeAPRSResponse(m aprs.Frame) []byte { 130 | return toAX25(m, clearSSIDMask, setSSIDMask) 131 | } 132 | -------------------------------------------------------------------------------- /ax25/radio.sample: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustin/go-aprs/0d55c6a34f676858975ba931cbc28af97117a5d1/ax25/radio.sample -------------------------------------------------------------------------------- /gate/.gitignore: -------------------------------------------------------------------------------- 1 | gate 2 | -------------------------------------------------------------------------------- /gate/gate.go: -------------------------------------------------------------------------------- 1 | // An APRS gateway. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "log/syslog" 11 | "net/http" 12 | "os" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/dustin/go-aprs" 18 | "github.com/dustin/go-aprs/aprsis" 19 | "github.com/dustin/go-aprs/ax25" 20 | "github.com/dustin/go-broadcast" 21 | "github.com/dustin/go-rs232" 22 | ) 23 | 24 | var ( 25 | server = flag.String("server", "second.aprs.net:14580", "APRS-IS upstream") 26 | httpAddr = flag.String("http", ":7373", "HTTP bind address") 27 | portString = flag.String("port", "", "Serial port KISS thing") 28 | call = flag.String("call", "", "Your callsign (for APRS-IS)") 29 | pass = flag.String("pass", "", "Your call pass (for APRS-IS)") 30 | filter = flag.String("filter", "", "Optional filter for APRS-IS server") 31 | rawlog = flag.String("rawlog", "", "Path to raw log messages") 32 | wdTime = flag.Duration("watchdog_time", 5*time.Minute, "Close connection if a message hasn't been heard in this long") 33 | ) 34 | 35 | var ( 36 | logWriter = io.Writer(ioutil.Discard) 37 | radio io.ReadWriteCloser 38 | ) 39 | 40 | func reporter(b broadcast.Broadcaster) { 41 | ch := make(chan interface{}) 42 | b.Register(ch) 43 | defer b.Unregister(ch) 44 | 45 | for msgi := range ch { 46 | msg := msgi.(aprs.Frame) 47 | pos, err := msg.Body.Position() 48 | if err == nil { 49 | log.Printf("%s sent a ``%v'' to %s: ``%s'' at %v", 50 | msg.Source, msg.Body.Type(), msg.Dest, msg.Body, pos) 51 | } else { 52 | log.Printf("%s sent a ``%v'' to %s: ``%s'' -- err=%v", msg.Source, 53 | msg.Body.Type(), msg.Dest, msg.Body, err) 54 | } 55 | } 56 | } 57 | 58 | type loggingInfoHandler struct{} 59 | 60 | var annoyinglog sync.Once 61 | 62 | func (*loggingInfoHandler) Info(msg string) { 63 | // Ignore this annoying repetitive message 64 | if strings.HasPrefix(msg, "# aprsc") { 65 | annoyinglog.Do(func() { 66 | log.Printf("info: %s", msg) 67 | }) 68 | return 69 | } 70 | log.Printf("info: %s", msg) 71 | 72 | } 73 | 74 | func netClient(b broadcast.Broadcaster) error { 75 | is, err := aprsis.Dial("tcp", *server) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | if err := is.Auth(*call, *pass, *filter); err != nil { 81 | return err 82 | } 83 | 84 | if *rawlog != "" { 85 | logWriter, err := os.OpenFile(*rawlog, 86 | os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) 87 | if err != nil { 88 | return err 89 | } 90 | is.SetRawLog(logWriter) 91 | } 92 | 93 | is.SetInfoHandler(&loggingInfoHandler{}) 94 | 95 | wd := time.AfterFunc(*wdTime, func() { _ = is.Close() }) 96 | for { 97 | msg, err := is.Next() 98 | if err != nil { 99 | return err 100 | } 101 | b.Submit(msg) 102 | wd.Reset(*wdTime) 103 | } 104 | } 105 | 106 | func readNet(b broadcast.Broadcaster) { 107 | if *call == "" { 108 | fmt.Fprintf(os.Stderr, "Your callsign is required.\n") 109 | flag.Usage() 110 | os.Exit(1) 111 | } 112 | if *pass == "" { 113 | fmt.Fprintf(os.Stderr, "Your call pass is required.\n") 114 | flag.Usage() 115 | os.Exit(1) 116 | } 117 | 118 | for { 119 | err := netClient(b) 120 | log.Printf("*** Error reading from net: %v (restarting)", err) 121 | time.Sleep(time.Second) 122 | } 123 | } 124 | 125 | func readSerial(b broadcast.Broadcaster) { 126 | var err error 127 | radio, err = rs232.OpenPort(*portString, 57600, rs232.S_8N1) 128 | if err != nil { 129 | log.Fatalf("Error opening port: %s", err) 130 | } 131 | 132 | d := ax25.NewDecoder(radio) 133 | for { 134 | msg, err := d.Next() 135 | if err != nil { 136 | log.Fatalf("Error retrieving APRS message via KISS: %v", err) 137 | } 138 | b.Submit(msg) 139 | } 140 | } 141 | 142 | func main() { 143 | var serverNet, serverAddr string 144 | flag.StringVar(&serverNet, "is-net", "tcp", "Network for APRS-IS server") 145 | flag.StringVar(&serverAddr, "is-addr", ":10152", "Bind address for APRS-IS server") 146 | useSyslog := flag.Bool("syslog", false, "Log to syslog") 147 | flag.Parse() 148 | 149 | if *useSyslog { 150 | sl, err := syslog.New(syslog.LOG_INFO, "aprs-gate") 151 | if err != nil { 152 | log.Fatalf("Error initializing syslog") 153 | } 154 | log.SetOutput(sl) 155 | log.SetFlags(0) 156 | } 157 | 158 | broadcaster := broadcast.NewBroadcaster(100) 159 | 160 | // go reporter(broadcaster) 161 | go notify(broadcaster) 162 | 163 | if *server != "" { 164 | go readNet(broadcaster) 165 | } 166 | 167 | if *portString != "" { 168 | go readSerial(broadcaster) 169 | } 170 | 171 | go startIS(serverNet, serverAddr, broadcaster) 172 | 173 | log.Fatal(http.ListenAndServe(*httpAddr, nil)) 174 | } 175 | -------------------------------------------------------------------------------- /gate/notifications.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/dustin/go-aprs" 15 | "github.com/dustin/go-broadcast" 16 | "github.com/dustin/go-nma" 17 | "github.com/pmylund/go-cache" 18 | "github.com/rem7/goprowl" 19 | ) 20 | 21 | const maxRetries = 10 22 | 23 | type notifier struct { 24 | Name string 25 | Driver string 26 | To string 27 | Disabled bool 28 | Config map[string]string 29 | } 30 | 31 | type notification struct { 32 | Event string `json:"event"` 33 | Msg string `json:"msg"` 34 | } 35 | 36 | type notifyFun func(n notifier, note notification) error 37 | 38 | var notifyFuns = map[string]notifyFun{ 39 | "prowl": notifyProwl, 40 | "webhook": notifyWebhook, 41 | "nma": notifyMyAndroid, 42 | } 43 | 44 | func notifyMyAndroid(n notifier, note notification) error { 45 | notifier := nma.New(n.Config["apikey"]) 46 | 47 | i, err := strconv.Atoi(n.Config["priority"]) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | msg := nma.Notification{ 53 | Application: n.Config["application"], 54 | Description: note.Msg, 55 | Event: note.Event, 56 | Priority: nma.PriorityLevel(i), 57 | } 58 | 59 | return notifier.Notify(&msg) 60 | } 61 | 62 | func notifyProwl(n notifier, note notification) error { 63 | p := goprowl.Goprowl{} 64 | if err := p.RegisterKey(n.Config["apikey"]); err != nil { 65 | return err 66 | } 67 | 68 | msg := goprowl.Notification{ 69 | Application: n.Config["application"], 70 | Description: note.Msg, 71 | Event: note.Event, 72 | Priority: n.Config["priority"], 73 | } 74 | 75 | return p.Push(&msg) 76 | } 77 | 78 | func notifyWebhook(n notifier, note notification) error { 79 | data, err := json.Marshal(note) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | r, err := http.Post(n.Config["url"], "application/json", 85 | strings.NewReader(string(data))) 86 | if err == nil { 87 | defer r.Body.Close() 88 | if r.StatusCode < 200 || r.StatusCode >= 300 { 89 | err = errors.New(r.Status) 90 | } 91 | } 92 | return err 93 | } 94 | 95 | func (n notifier) notify(note notification) { 96 | log.Printf("Sending notification: %v", note) 97 | for i := 0; i < maxRetries; i++ { 98 | if err := notifyFuns[n.Driver](n, note); err == nil { 99 | break 100 | } else { 101 | time.Sleep(1 * time.Second) 102 | log.Printf("Retrying notification %s due to %v", n.Name, err) 103 | } 104 | } 105 | } 106 | 107 | func loadNotifiers(path string) ([]notifier, error) { 108 | notifiers := []notifier{} 109 | 110 | f, err := os.Open(path) 111 | if err != nil { 112 | return notifiers, err 113 | } 114 | defer f.Close() 115 | 116 | d := json.NewDecoder(f) 117 | if err = d.Decode(¬ifiers); err != nil { 118 | return notifiers, err 119 | } 120 | 121 | for _, v := range notifiers { 122 | if _, ok := notifyFuns[v.Driver]; !ok { 123 | log.Fatalf("Unknown driver '%s' in '%s'", v.Driver, v.Name) 124 | } 125 | } 126 | 127 | return notifiers, nil 128 | } 129 | 130 | func notify(b broadcast.Broadcaster) { 131 | notifiers, err := loadNotifiers("notify.json") 132 | if err != nil { 133 | notifiers = []notifier{} 134 | log.Printf("No notifiers loaded because %v", err) 135 | } 136 | 137 | ch := make(chan interface{}) 138 | b.Register(ch) 139 | defer b.Unregister(ch) 140 | 141 | c := cache.New(time.Hour, time.Minute) 142 | 143 | for msgi := range ch { 144 | msg := msgi.(aprs.Frame) 145 | sender := msg.Source 146 | for msg.Body.Type().IsThirdParty() && len(msg.Body) > 1 { 147 | msg = aprs.ParseFrame(string(msg.Body[1:])) 148 | } 149 | k := fmt.Sprintf("%v %v %v", msg.Dest, msg.Source, msg.Body) 150 | 151 | _, found := c.Get(k) 152 | if found { 153 | // Already processed this one. 154 | continue 155 | } 156 | 157 | c.Set(k, "hi", 0) 158 | 159 | note := notification{msg.Body.Type().String(), fmt.Sprintf("%s: %s", sender, msg.Body)} 160 | m := msg.Message() 161 | if m.Parsed { 162 | note.Msg = fmt.Sprintf("%s: %s", sender, m.Body) 163 | } 164 | for _, n := range notifiers { 165 | if n.To == msg.Dest.Call || (m.Parsed && m.Recipient.Call == n.To && !m.IsACK()) { 166 | go n.notify(note) 167 | } else if m.IsBulletin() && n.To == "BLN" { 168 | note.Msg = fmt.Sprintf("BLN: %s", msg.Body) 169 | go n.notify(note) 170 | } 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /gate/notify-sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "NMA", 4 | "driver": "nma", 5 | "to": "YOURCALLSIGN", 6 | "config": { 7 | "apikey": "YOURAPIKEY", 8 | "application": "aprs", 9 | "priority": "0" 10 | } 11 | } 12 | ] 13 | 14 | -------------------------------------------------------------------------------- /gate/point_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | ) 7 | 8 | func TestPointDistance(t *testing.T) { 9 | p1 := Point{40.6892, -74.0444} 10 | p2 := Point{48.8583, 2.2945} 11 | 12 | d := p1.Distance(p2) 13 | exp := 5837.42231774235 14 | if math.Abs(d-exp) > .001 { 15 | t.Fatalf("Expected %v, got %v", exp, d) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /gate/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "net" 8 | 9 | "github.com/dustin/go-aprs" 10 | "github.com/dustin/go-broadcast" 11 | ) 12 | 13 | // A Filter limits the packets that are received over APRS-IS 14 | type Filter interface { 15 | Matches(d aprs.Frame) bool 16 | } 17 | 18 | // CompositeFilter is a filter made up of other filters 19 | type CompositeFilter struct { 20 | Positive []Filter 21 | Negative []Filter 22 | } 23 | 24 | // Matches satisfies Filter 25 | func (c *CompositeFilter) Matches(d aprs.Frame) bool { 26 | rv := false 27 | for _, f := range c.Positive { 28 | if f.Matches(d) { 29 | rv = true 30 | break 31 | } 32 | } 33 | if rv { 34 | for _, f := range c.Negative { 35 | if f.Matches(d) { 36 | rv = false 37 | break 38 | } 39 | } 40 | } 41 | return rv 42 | } 43 | 44 | // A Point is a latitude/longitude pair representing a geographical location. 45 | type Point struct { 46 | Lat float64 47 | Lon float64 48 | } 49 | 50 | func d2r(d float64) float64 { 51 | return d * 0.0174532925 52 | } 53 | 54 | // RadLat returns the latitude in radians. 55 | func (p Point) RadLat() float64 { 56 | return d2r(p.Lat) 57 | } 58 | 59 | // RadLon returns the longitude to radians. 60 | func (p Point) RadLon() float64 { 61 | return d2r(p.Lon) 62 | } 63 | 64 | // Distance returns the approximate distance from another point in kilometers. 65 | func (p Point) Distance(p2 Point) float64 { 66 | r := 6371.01 67 | return math.Acos((math.Sin(p.RadLat())* 68 | math.Sin(p2.RadLat()))+ 69 | (math.Cos(p.RadLat())*math.Cos(p2.RadLat())* 70 | math.Cos(p.RadLon()-p2.RadLon()))) * r 71 | } 72 | 73 | func handleIS(conn net.Conn, b broadcast.Broadcaster) { 74 | ch := make(chan interface{}, 100) 75 | 76 | _, err := fmt.Fprintf(conn, "# goaprs\n") 77 | if err != nil { 78 | log.Printf("Error sending banner: %v", err) 79 | } 80 | 81 | b.Register(ch) 82 | defer b.Unregister(ch) 83 | 84 | for m := range ch { 85 | _, err = fmt.Fprintln(conn, m) 86 | if err != nil { 87 | log.Printf("Error on connection: %v", err) 88 | return 89 | } 90 | } 91 | } 92 | 93 | func startIS(n, addr string, b broadcast.Broadcaster) { 94 | ln, err := net.Listen(n, addr) 95 | if err != nil { 96 | log.Fatal(err) 97 | } 98 | for { 99 | conn, err := ln.Accept() 100 | if err != nil { 101 | log.Printf("Error accepting connections: %v", err) 102 | continue 103 | } 104 | go handleIS(conn, b) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /gate/web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/dustin/go-aprs" 12 | "github.com/dustin/go-aprs/ax25" 13 | ) 14 | 15 | func init() { 16 | http.HandleFunc("/", sendMessage) 17 | } 18 | 19 | func sendMessage(w http.ResponseWriter, r *http.Request) { 20 | src := r.FormValue("src") 21 | dest := r.FormValue("dest") 22 | text := r.FormValue("msg") 23 | if radio == nil { 24 | http.Error(w, "No radio", 500) 25 | return 26 | } 27 | 28 | if text != "" { 29 | d := hex.Dumper(os.Stdout) 30 | defer d.Close() 31 | mw := io.MultiWriter(d, radio) 32 | 33 | _, err := mw.Write([]byte{0xc0, 0x00}) 34 | if err != nil { 35 | http.Error(w, err.Error(), 500) 36 | log.Printf("Error writing command: %v", err) 37 | return 38 | } 39 | 40 | msg := aprs.Frame{ 41 | Source: aprs.AddressFromString(src), 42 | Dest: aprs.AddressFromString(dest), 43 | Path: []aprs.Address{ 44 | aprs.AddressFromString("WIDE2-2")}, 45 | Body: aprs.Info(text), 46 | } 47 | 48 | body := ax25.EncodeAPRSCommand(msg) 49 | _, err = mw.Write(body) 50 | if err != nil { 51 | http.Error(w, err.Error(), 500) 52 | log.Printf("Error writing command: %v", err) 53 | return 54 | } 55 | 56 | _, err = mw.Write([]byte{0xc0}) 57 | if err != nil { 58 | http.Error(w, err.Error(), 500) 59 | log.Printf("Error finishing command: %v", err) 60 | return 61 | } 62 | 63 | fmt.Fprintf(w, "Message sent") 64 | } else { 65 | http.Error(w, "No message", 400) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /logs/igate.log.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustin/go-aprs/0d55c6a34f676858975ba931cbc28af97117a5d1/logs/igate.log.gz -------------------------------------------------------------------------------- /logs/message.log.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustin/go-aprs/0d55c6a34f676858975ba931cbc28af97117a5d1/logs/message.log.gz -------------------------------------------------------------------------------- /logs/tnc.log.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustin/go-aprs/0d55c6a34f676858975ba931cbc28af97117a5d1/logs/tnc.log.gz -------------------------------------------------------------------------------- /messages.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // A Message is a message from an APRS address to another. 10 | type Message struct { 11 | Sender Address 12 | Recipient Address 13 | Body string 14 | ID string 15 | Parsed bool 16 | } 17 | 18 | // Message returns the message from an Frame frame. 19 | func (a Frame) Message() Message { 20 | // Find source of third party 21 | for a.Body.Type().IsThirdParty() && len(a.Body) > 11 { 22 | a = ParseFrame(string(a.Body[1:])) 23 | } 24 | 25 | rv := Message{} 26 | if a.Body.Type().IsMessage() { 27 | if len(a.Body) < 12 { 28 | return rv 29 | } 30 | rv.Sender = a.Source 31 | rv.Recipient = AddressFromString(strings.TrimSpace(string(a.Body[1:10]))) 32 | parts := strings.SplitN(string(a.Body[11:]), "{", 2) 33 | rv.Body = parts[0] 34 | if len(parts) > 1 { 35 | rv.ID = parts[1] 36 | } 37 | 38 | rv.Parsed = true 39 | } 40 | return rv 41 | } 42 | 43 | func (m Message) String() string { 44 | idstring := "" 45 | if m.ID != "" { 46 | idstring = "{" + m.ID 47 | } 48 | return fmt.Sprintf(":%-9s:%s%s", m.Recipient.String(), 49 | m.Body, idstring) 50 | } 51 | 52 | var ( 53 | ackPattern = regexp.MustCompile(`^ack([A-z0-9]{1,5})`) 54 | blnPattern = regexp.MustCompile(`^:BLN[0-9] :(.*)`) 55 | annPattern = regexp.MustCompile(`^:BLN[A-Z] :(.*)`) 56 | ) 57 | 58 | // IsACK returns true if this message is an acknowledgment to another message. 59 | func (m Message) IsACK() bool { 60 | return ackPattern.MatchString(m.Body) 61 | } 62 | 63 | // IsBulletin returns true if the message represents a bulletin. 64 | func (m Message) IsBulletin() bool { 65 | return blnPattern.MatchString(m.String()) 66 | } 67 | 68 | // IsAnnouncement returns true if the message represents an announcement. 69 | func (m Message) IsAnnouncement() bool { 70 | return annPattern.MatchString(m.String()) 71 | } 72 | -------------------------------------------------------------------------------- /messages_test.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | const ( 8 | MESSAGE = "KG6HWF-9>APDR12,TCPIP*,qAC,T2SPAIN2::KG6HWF :testing notifications{10" 9 | MESSAGE2 = "K7FED-10>APJI23,WR6ABD*:}KG6HWE>APOA00,TCPIP,K7FED-10*::KG6HWF :yo{AB}07" 10 | ACKED = "KG6HWF-5>APDR13,TCPIP*,qAC,T2PERTH::KG6HWF :ack01}1" 11 | BULLETIN = "W6ELA-7>APK003,WIDE1-1,WIDE2-1,qAR,KW0RCA-2::BLN1 :packet net at 8pm on 145.050. w6ela hears klprc3" 12 | ) 13 | 14 | func TestMessage(t *testing.T) { 15 | v := ParseFrame(MESSAGE) 16 | msg := v.Message() 17 | 18 | if !msg.Parsed { 19 | t.Fatalf("Couldn't parse %v as a message", v) 20 | } 21 | if msg.Sender.String() != "KG6HWF-9" { 22 | t.Fatalf("Didn't find the sender: %v", msg.Recipient) 23 | } 24 | if msg.Recipient.String() != "KG6HWF" { 25 | t.Fatalf("Didn't find the receipient: %v", msg.Recipient) 26 | } 27 | if msg.Body != "testing notifications" { 28 | t.Fatalf("Didn't get the message: %#v from %#v", msg.Body, v.Body) 29 | } 30 | if msg.ID != "10" { 31 | t.Fatalf("Expected msg id 10, got %v", msg.ID) 32 | } 33 | } 34 | 35 | func TestAcked(t *testing.T) { 36 | v := ParseFrame(ACKED) 37 | msg := v.Message() 38 | 39 | if !msg.IsACK() { 40 | t.Fatalf("Expected %v to be interpreted as an ACK", ACKED) 41 | } 42 | } 43 | 44 | func TestBrokenMessage(t *testing.T) { 45 | a := Frame{Body: ":"} 46 | msg := a.Message() 47 | if msg.Parsed { 48 | t.Fatalf("Expected to fail to parse broken message: %v", msg) 49 | } 50 | } 51 | 52 | func TestThirdParty(t *testing.T) { 53 | v := ParseFrame(MESSAGE2) 54 | if !v.Body.Type().IsThirdParty() { 55 | t.Fatalf("This should be third party traffic: %#v", v.Body) 56 | } 57 | msg := v.Message() 58 | 59 | if !msg.Parsed { 60 | t.Fatalf("Couldn't parse %v as a message", v) 61 | } 62 | if msg.Sender.String() != "KG6HWE" { 63 | t.Fatalf("Incorrect sender: %v", v.Source) 64 | } 65 | if msg.Recipient.String() != "KG6HWF" { 66 | t.Fatalf("Didn't find the receipient: %v", msg.Recipient) 67 | } 68 | if msg.Body != "yo" { 69 | t.Fatalf("Didn't get the message: %#v from %#v", msg.Body, v.Body) 70 | } 71 | if msg.ID != "AB}07" { 72 | t.Fatalf("Expected msg id AB}07, got %v", msg.ID) 73 | } 74 | } 75 | 76 | func TestBulletin(t *testing.T) { 77 | v := ParseFrame(BULLETIN) 78 | if !v.Message().IsBulletin() { 79 | t.Fatalf("This should be a bulletin: %#v", v.Body) 80 | } 81 | } 82 | 83 | func TestMessageEncoding(t *testing.T) { 84 | exp := ":KG6HWF :yo{AB}07" 85 | m := Message{Sender: AddressFromString("KG6HWE"), 86 | Recipient: AddressFromString("KG6HWF"), 87 | Body: "yo", 88 | ID: "AB}07", 89 | } 90 | if m.String() != exp { 91 | t.Fatalf("Expected %v, got %v", exp, m.String()) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /position.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "unicode" 11 | ) 12 | 13 | const coordField = `(\d{1,3})([0-5 ][0-9 ])\.([0-9 ]+)([NEWS])` 14 | const b91chars = "[!\"#$%&'()*+,-./0123456789:;<=>?@" + 15 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`" + 16 | "abcdefghijklmnopqrstuvwxyz{']" 17 | 18 | const symbolTables = `[0-9/\\A-z]` 19 | 20 | var uncompressedPositionRegexp = regexp.MustCompile(`([!=]|[/@\*]\d{6}[hz/])` + 21 | coordField + "(" + symbolTables + ")" + coordField + "(.)([0-3][0-9]{2}/[0-9]{3})?") 22 | var compressedPositionRegexp = regexp.MustCompile("([!=/@])(" + 23 | b91chars + "{4})(" + b91chars + "{4})(.)(..)(.)") 24 | 25 | // ErrNoPosition is returned when no geo positions could be found in a message. 26 | var ErrNoPosition = errors.New("no positions found") 27 | 28 | // ErrTruncatedMsg is returned when a message is incomplete. 29 | var ErrTruncatedMsg = errors.New("truncated message") 30 | 31 | // Symbol represents the map marker symbol for an object or station. 32 | type Symbol struct { 33 | Table byte 34 | Symbol byte 35 | } 36 | 37 | // IsPrimary is true if this symbol is part of the primary symbol table. 38 | func (s Symbol) IsPrimary() bool { 39 | return s.Table != '\\' 40 | } 41 | 42 | // Name is the name of the symbol. 43 | func (s Symbol) Name() (rv string) { 44 | m := primarySymbolMap 45 | if !s.IsPrimary() { 46 | m = alternateSymbolMap 47 | } 48 | return m[s.Symbol] 49 | } 50 | 51 | // Glyph returns a textual representation of this Symbol. 52 | func (s Symbol) Glyph() string { 53 | return symbolGlyphs[s.Name()] 54 | } 55 | 56 | func (s Symbol) String() (rv string) { 57 | g := s.Glyph() 58 | if g == "" { 59 | rv = fmt.Sprintf("{%c%c: %s}", s.Table, s.Symbol, s.Name()) 60 | } else { 61 | rv = fmt.Sprintf("{%c%c: %s - %s}", s.Table, s.Symbol, s.Name(), g) 62 | } 63 | return 64 | } 65 | 66 | // Velocity represents the course and speed of an object or station. 67 | type Velocity struct { 68 | Course float64 69 | Speed float64 70 | } 71 | 72 | // Position contains all of the information necessary for placing an object on a map. 73 | type Position struct { 74 | Lat float64 75 | Lon float64 76 | Ambiguity int 77 | Velocity Velocity 78 | Symbol Symbol 79 | } 80 | 81 | func (p Position) String() string { 82 | return fmt.Sprintf("{lat=%v, lon=%v, amb=%v, sym=%v}", 83 | p.Lat, p.Lon, p.Ambiguity, p.Symbol) 84 | } 85 | 86 | func uncompressedParser(input string) (pos Position, err error) { 87 | // lat:8 symtab:1 lon:9 sym:1 88 | if len(input) < 19 { 89 | return pos, ErrTruncatedMsg 90 | } 91 | 92 | pos.Symbol.Table = input[8] 93 | pos.Symbol.Symbol = input[18] 94 | 95 | nums := []float64{0, 0, 0, 0} 96 | toparse := []string{input[0:2], input[2:7], input[9:12], input[12:17]} 97 | 98 | for i, p := range toparse { 99 | converted := strings.Map(func(r rune) (rv rune) { 100 | rv = r 101 | if r == ' ' { 102 | pos.Ambiguity++ 103 | rv = '0' 104 | } 105 | return 106 | }, p) 107 | n, err := strconv.ParseFloat(converted, 64) 108 | if err != nil { 109 | return pos, err 110 | } 111 | nums[i] = n 112 | } 113 | 114 | a := nums[0] + (nums[1] / 60) 115 | b := nums[2] + (nums[3] / 60) 116 | 117 | pos.Ambiguity /= 2 118 | offby := 0.0 119 | switch pos.Ambiguity { 120 | case 0: 121 | // This is exact 122 | case 1: 123 | // Nearest 1/10 of a minute 124 | offby = 0.05 / 60.0 125 | case 2: 126 | // Nearest minute 127 | offby = 0.5 / 60.0 128 | case 3: 129 | // Nearest 10 minutes 130 | offby = 5.0 / 60.0 131 | case 4: 132 | // Nearest degree 133 | offby = 0.5 134 | default: 135 | return pos, fmt.Errorf("invalid position ambiguity %d from %v", 136 | pos.Ambiguity, input) 137 | } 138 | if offby > 0 { 139 | a += offby 140 | b += offby 141 | } 142 | 143 | if input[7] == 'S' { 144 | a = 0 - a 145 | } 146 | if input[17] == 'W' { 147 | b = 0 - b 148 | } 149 | 150 | pos.Lat = a 151 | pos.Lon = b 152 | 153 | ext := input[19:] 154 | if len(ext) >= 7 && pos.Symbol.Symbol != '_' && ext[3] == '/' { 155 | fmt.Sscanf(ext, "%f/%f", 156 | &pos.Velocity.Course, &pos.Velocity.Speed) 157 | pos.Velocity.Speed *= 1.852 158 | } 159 | 160 | return 161 | } 162 | 163 | func positionUncompressed(input string) (Position, error) { 164 | found := uncompressedPositionRegexp.FindAllStringSubmatch(input, 10) 165 | // {"=3722.1 N/12159.1 W-", "=", "37", "22", "1 ", "N", "/", "121", "59", "1 ", "W", "-", ""} 166 | if len(found) == 0 || len(found[0]) != 13 { 167 | return Position{}, ErrNoPosition 168 | } 169 | pos := Position{} 170 | pos.Symbol.Table = found[0][6][0] 171 | pos.Symbol.Symbol = found[0][11][0] 172 | nums := []float64{0, 0, 0, 0} 173 | toparse := []string{found[0][2], found[0][3] + "." + found[0][4], 174 | found[0][7], found[0][8] + "." + found[0][9]} 175 | for i, p := range toparse { 176 | converted := strings.Map(func(r rune) (rv rune) { 177 | rv = r 178 | if r == ' ' { 179 | pos.Ambiguity++ 180 | rv = '0' 181 | } 182 | return 183 | }, p) 184 | n, err := strconv.ParseFloat(converted, 64) 185 | if err != nil { 186 | return pos, err 187 | } 188 | nums[i] = n 189 | } 190 | 191 | a := nums[0] + (nums[1] / 60) 192 | b := nums[2] + (nums[3] / 60) 193 | 194 | pos.Ambiguity /= 2 195 | offby := 0.0 196 | switch pos.Ambiguity { 197 | case 0: 198 | // This is exact 199 | case 1: 200 | // Nearest 1/10 of a minute 201 | offby = 0.05 / 60.0 202 | case 2: 203 | // Nearest minute 204 | offby = 0.5 / 60.0 205 | case 3: 206 | // Nearest 10 minutes 207 | offby = 5.0 / 60.0 208 | case 4: 209 | // Nearest degree 210 | offby = 0.5 211 | default: 212 | return pos, fmt.Errorf("invalid position ambiguity %d from %v", 213 | pos.Ambiguity, found[0][0]) 214 | } 215 | if offby > 0 { 216 | a += offby 217 | b += offby 218 | } 219 | 220 | if found[0][5] == "S" || found[0][5] == "W" { 221 | a = 0 - a 222 | } 223 | if found[0][10] == "W" || found[0][10] == "S" { 224 | b = 0 - b 225 | } 226 | 227 | if found[0][5] == "N" || found[0][5] == "S" { 228 | pos.Lat = a 229 | pos.Lon = b 230 | } else { 231 | pos.Lat = b 232 | pos.Lon = a 233 | } 234 | 235 | if found[0][12] != "" && pos.Symbol.Symbol != '_' { 236 | fmt.Sscanf(found[0][12], "%f/%f", 237 | &pos.Velocity.Course, &pos.Velocity.Speed) 238 | pos.Velocity.Speed *= 1.852 239 | } 240 | 241 | return pos, nil 242 | } 243 | 244 | func decodeBase91(s []byte) int { 245 | if len(s) != 4 { 246 | return 0 247 | } 248 | return ((int(s[0]) - 33) * 91 * 91 * 91) + ((int(s[1]) - 33) * 91 * 91) + 249 | ((int(s[2]) - 33) * 91) + int(s[3]) - 33 250 | } 251 | 252 | func positionCompressed(input string) (Position, error) { 253 | found := compressedPositionRegexp.FindAllStringSubmatch(input, 10) 254 | // {"/]\"4-}Foo !w6", "/", "]\"4-", "}Foo", " ", "!w", "6"}} 255 | if len(found) == 0 || len(found[0]) != 7 { 256 | return Position{}, ErrNoPosition 257 | } 258 | 259 | // Lat = 90 - ((y1-33) x 91^3 + (y2-33) x 91^2 + (y3-33) x 91 + y4-33) / 380926 260 | // Long = -180 + ((x1-33) x 91^3 + (x2-33) x 91^2 + (x3-33) x 91 + x4-33) / 190463 261 | 262 | pos := Position{ 263 | Lat: 90 - float64(decodeBase91([]byte(found[0][2])))/380926, 264 | Lon: -180 + float64(decodeBase91([]byte(found[0][3])))/190463, 265 | } 266 | 267 | cs := found[0][5] 268 | if cs[0] != ' ' && cs[1] != ' ' && int(cs[0]) >= '!' && int(cs[0]) <= 'z' { 269 | pos.Velocity.Course = (float64(cs[0]) - 33) * 4 270 | if pos.Velocity.Course == 0 { 271 | pos.Velocity.Course = 360 272 | } 273 | pos.Velocity.Speed = 1.852 * (math.Pow(1.08, float64(cs[1]-33)) - 1) 274 | 275 | } 276 | 277 | return pos, nil 278 | } 279 | 280 | func positionOld(t string) (Position, error) { 281 | pos, err := positionUncompressed(t) 282 | if err == nil { 283 | return pos, err 284 | } 285 | return positionCompressed(t) 286 | } 287 | 288 | func compressedParser(input string) (Position, error) { 289 | if len(input) < 12 { 290 | return Position{}, ErrTruncatedMsg 291 | } 292 | pos := Position{} 293 | pos.Symbol.Table = input[0] 294 | pos.Symbol.Symbol = input[9] 295 | pos.Lat = 90 - float64(decodeBase91([]byte(input[1:5])))/380926 296 | pos.Lon = -180 + float64(decodeBase91([]byte(input[5:9])))/190463 297 | if input[10] != ' ' && input[11] != ' ' && int(input[10]) >= '!' && int(input[10]) <= 'z' { 298 | pos.Velocity.Course = (float64(input[10]) - 33) * 4 299 | if pos.Velocity.Course == 0 { 300 | pos.Velocity.Course = 360 301 | } 302 | pos.Velocity.Speed = 1.852 * (math.Pow(1.08, float64(input[11]-33)) - 1) 303 | } 304 | return pos, nil 305 | } 306 | 307 | func newParser(input string, uncompressed bool) (Position, error) { 308 | if uncompressed { 309 | return uncompressedParser(input) 310 | } 311 | return compressedParser(input) 312 | } 313 | 314 | // Position gets the position of the message. 315 | func (body Info) Position() (Position, error) { 316 | switch body.Type() { 317 | case '!', '=': 318 | t := string(body) 319 | if len(body) < 2 { 320 | return Position{}, ErrTruncatedMsg 321 | } 322 | return newParser(t[1:], unicode.IsDigit(rune(t[1]))) 323 | case '/', '@': 324 | if len(body) < 9 { 325 | return Position{}, ErrTruncatedMsg 326 | } 327 | t := string(body[8:]) 328 | return newParser(t, unicode.IsDigit(rune(body[8]))) 329 | case ';': 330 | t := string(body) 331 | if len(t) < 19 { 332 | return Position{}, ErrTruncatedMsg 333 | } 334 | return newParser(t[18:], unicode.IsDigit(rune(body[18]))) 335 | // t := string(body[1:]) 336 | // name := strings.TrimSpace(t[1:9]) 337 | // live := t[9] == '*' 338 | // ts := t[10:17] 339 | case ')': 340 | // item 341 | } 342 | return positionOld(string(body)) 343 | } 344 | -------------------------------------------------------------------------------- /position_test.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestSymbol(t *testing.T) { 9 | tests := []struct { 10 | in Symbol 11 | s string 12 | }{ 13 | {Symbol{'/', '\''}, `{/': Plane sm - ✈}`}, 14 | {Symbol{'\\', '\''}, `{\': Crash site}`}, 15 | {Symbol{'/', 'a'}, "{/a: Ambulance - \u2620}"}, 16 | } 17 | 18 | for _, test := range tests { 19 | if test.in.String() != test.s { 20 | t.Errorf("On %#v.String() = %v, want %v", test.in, test.in.String(), test.s) 21 | } 22 | } 23 | } 24 | 25 | func TestPosition(t *testing.T) { 26 | p := Position{37, -121, 2, Velocity{15, 31}, Symbol{'/', 'a'}} 27 | exp := "{lat=37, lon=-121, amb=2, sym={/a: Ambulance - \u2620}}" 28 | if p.String() != exp { 29 | t.Errorf("for %#v, got %v, want %v", p, p, exp) 30 | } 31 | } 32 | 33 | func TestDecodeBase91(t *testing.T) { 34 | tests := []struct { 35 | in []byte 36 | exp int 37 | }{ 38 | {nil, 0}, 39 | {[]byte{0, 0, 0, 0}, -25144152}, 40 | {[]byte{1, 0, 0, 0}, -24390581}, 41 | {[]byte{1, 0, 0, 1}, -24390580}, 42 | {[]byte{1, 0, 0xff, 1}, -24367375}, 43 | {[]byte("<*e7"), 20346417 + 74529 + 6188 + 22}, 44 | } 45 | 46 | for _, test := range tests { 47 | got := decodeBase91(test.in) 48 | if got != test.exp { 49 | t.Errorf("decodeBase64(%v) = %d, want %d", test.in, got, test.exp) 50 | } 51 | } 52 | } 53 | 54 | func TestPositionParsing(t *testing.T) { 55 | x, err := compressedParser("123") 56 | if err == nil { 57 | t.Errorf("Expected error on three bytes compressed, got %v", x) 58 | } 59 | 60 | x, err = uncompressedParser("123") 61 | if err == nil { 62 | t.Errorf("Expected error on three bytes uncompressed, got %v", x) 63 | } 64 | } 65 | 66 | func TestInvalidPosition(t *testing.T) { 67 | testBodies := []string{ 68 | "@", 69 | "@1234568", 70 | "!", 71 | "!1", 72 | ";", 73 | ";123456789012345678", 74 | } 75 | for i, testFrame := range testBodies { 76 | t.Run(fmt.Sprintf("InvalidPosition[%d]", i), func(t *testing.T) { 77 | parsedFrame := ParseFrame("SOURCE>DESTINATION,PATH:" + testFrame) 78 | _, err := parsedFrame.Body.Position() 79 | if err == nil { 80 | t.Fatalf("Parsing %q: expecting any error, go not error", testFrame) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /samples/faptests.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustin/go-aprs/0d55c6a34f676858975ba931cbc28af97117a5d1/samples/faptests.json -------------------------------------------------------------------------------- /samples/large.log.bz2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustin/go-aprs/0d55c6a34f676858975ba931cbc28af97117a5d1/samples/large.log.bz2 -------------------------------------------------------------------------------- /symbols.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "log" 7 | "strings" 8 | ) 9 | 10 | var primarySymbolMap map[byte]string 11 | var alternateSymbolMap map[byte]string 12 | 13 | // Pasted from http://wa8lmf.net/miscinfo/APRSsymbolcodes.txt 14 | var symbolTableTextFile = ` 15 | REM Revised by WA8LMF 28 Sept 2005 16 | REM Based on original file by G4IDE supplied with UI-View 17 | 18 | REM Primary Table Alternate Table 19 | REM Symbol GPSxyz Index Description GPSxyz Index Description 20 | REM ------ ------ ----- ----------- ------ ----- ----------- 21 | !, BB, 0, Police Stn, OB, 0, Emergency, 22 | ", BC, 1, No Symbol, OC, 1, No Symbol 23 | #, BD, 2, Digi, OD, 2, No. Digi 24 | $, BE, 3, Phone, OE, 3, Bank, 📱, 25 | %, BF, 4, DX Cluster, OF, 4, No Symbol 26 | &, BG, 5, HF Gateway, OG, 5, No. Diam'd, Ⓖ, 27 | ', BH, 6, Plane sm, OH, 6, Crash site, ✈, 28 | (, BI, 7, Mob Sat Stn, OI, 7, Cloudy 29 | ), BJ, 8, WheelChair, OJ, 8, MEO, ♿, 30 | *, BK, 9, Snowmobile, OK, 9, Snow 31 | +, BL, 10, Red Cross, OL, 10, Church 32 | ,, BM, 11, Boy Scout, OM, 11, Girl Scout 33 | -, BN, 12, Home, ON, 12, Home (HF), ⌂, 34 | ., BO, 13, X, OO, 13, UnknownPos 35 | /, BP, 14, Red Dot, OP, 14, Destination, ·, 36 | 0, P0, 15, Circle (0), A0, 15, No. Circle, ⓪, 37 | 1, P1, 16, Circle (1), A1, 16, No Symbol, ①, 38 | 2, P2, 17, Circle (2), A2, 17, No Symbol, ②, 39 | 3, P3, 18, Circle (3), A3, 18, No Symbol, ③, 40 | 4, P4, 19, Circle (4), A4, 19, No Symbol, ④, 41 | 5, P5, 20, Circle (5), A5, 20, No Symbol, ⑤, 42 | 6, P6, 21, Circle (6), A6, 21, No Symbol, ⑥, 43 | 7, P7, 22, Circle (7), A7, 22, No Symbol, ⑦, 44 | 8, P8, 23, Circle (8), A8, 23, No Symbol, ⑧, 45 | 9, P9, 24, Circle (9), A9, 24, Petrol Stn, ⑨, 46 | :, MR, 25, Fire, NR, 25, Hail, 🔥, 47 | ;, MS, 26, Campground, NS, 26, Park, 🏕,🏞 48 | <, MT, 27, Motorcycle, NT, 27, Gale Fl, 🛵, 49 | =, MU, 28, Rail Eng., NU, 28, No Symbol, 🚆, 50 | >, MV, 29, Car, NV, 29, No. Car, 🚗, 51 | ?, MW, 30, File svr, NW, 30, Info Kiosk 52 | @, MX, 31, HC Future, NX, 31, Hurricane 53 | A, PA, 32, Aid Stn, AA, 32, No. Box 54 | B, PB, 33, BBS, AB, 33, Snow blwng 55 | C, PC, 34, Canoe, AC, 34, Coast G'rd, 🛶, 56 | D, PD, 35, No Symbol, AD, 35, Drizzle 57 | E, PE, 36, Eyeball, AE, 36, Smoke, 👁, 58 | F, PF, 37, Tractor, AF, 37, Fr'ze Rain, 🚜, 59 | G, PG, 38, Grid Squ., AG, 38, Snow Shwr 60 | H, PH, 39, Hotel, AH, 39, Haze, 🏨, 61 | I, PI, 40, Tcp/ip, AI, 40, Rain Shwr 62 | J, PJ, 41, No Symbol, AJ, 41, Lightning, , ☇ 63 | K, PK, 42, School, AK, 42, Kenwood, 🏫, 64 | L, PL, 43, Usr Log-ON, AL, 43, Lighthouse, , ⛯ 65 | M, PM, 44, MacAPRS, AM, 44, No Symbol 66 | N, PN, 45, NTS Stn, AN, 45, Nav Buoy 67 | O, PO, 46, Balloon, AO, 46, Rocket, 🎈, 🚀 68 | P, PP, 47, Police, AP, 47, Parking, 👮, 69 | Q, PQ, 48, TBD, AQ, 48, Quake 70 | R, PR, 49, Rec Veh'le, AR, 49, Restaurant, 🚙, 71 | S, PS, 50, Shuttle, AS, 50, Sat/Pacsat 72 | T, PT, 51, SSTV, AT, 51, T'storm 73 | U, PU, 52, Bus, AU, 52, Sunny, 🚌, 74 | V, PV, 53, ATV, AV, 53, VORTAC 75 | W, PW, 54, WX Service, AW, 54, No. WXS 76 | X, PX, 55, Helo, AX, 55, Pharmacy 77 | Y, PY, 56, Yacht, AY, 56, No Symbol 78 | Z, PZ, 57, WinAPRS, AZ, 57, No Symbol 79 | [, HS, 58, Jogger, DS, 58, Wall Cloud 80 | \, HT, 59, Triangle, DT, 59, No Symbol 81 | ], HU, 60, PBBS, DU, 60, No Symbol 82 | ^, HV, 61, Plane lrge, DV, 61, No. Plane 83 | _, HW, 62, WX Station, DW, 62, No. WX Stn, ☀, 84 | ` + "`" + `, HX, 63, Dish Ant., DX, 63, Rain 85 | a, LA, 64, Ambulance, SA, 64, No. Diamond, ☠, 86 | b, LB, 65, Bike, SB, 65, Dust blwng, 🚲, 87 | c, LC, 66, ICP, SC, 66, No. CivDef 88 | d, LD, 67, Fire Station, SD, 67, DX Spot 89 | e, LE, 68, Horse, SE, 68, Sleet, 🐎, 90 | f, LF, 69, Fire Truck, SF, 69, Funnel Cld, 🚒, 91 | g, LG, 70, Glider, SG, 70, Gale 92 | h, LH, 71, Hospital, SH, 71, HAM store, 🏥, 93 | i, LI, 72, IOTA, SI, 72, No. Blk Box 94 | j, LJ, 73, Jeep, SJ, 73, WorkZone 95 | k, LK, 74, Truck, SK, 74, SUV 96 | l, LL, 75, Laptop, SL, 75, Area Locns 97 | m, LM, 76, Mic-E Rptr, SM, 76, Milepost 98 | n, LN, 77, Node, SN, 77, No. Triang 99 | o, LO, 78, EOC, SO, 78, Circle sm 100 | p, LP, 79, Rover, SP, 79, Part Cloud 101 | q, LQ, 80, Grid squ., SQ, 80, No Symbol 102 | r, LR, 81, Antenna, SR, 81, Restrooms, ⏉, 103 | s, LS, 82, Power Boat, SS, 82, No. Boat 104 | t, LT, 83, Truck Stop, ST, 83, Tornado 105 | u, LU, 84, Truck 18wh, SU, 84, No. Truck 106 | v, LV, 85, Van, SV, 85, No. Van 107 | w, LW, 86, Water Stn, SW, 86, Flooding 108 | x, LX, 87, XAPRS, SX, 87, No Symbol 109 | y, LY, 88, Yagi, SY, 88, Sky Warn, ⏉, 110 | z, LZ, 89, Shelter, SZ, 89, No. Shelter 111 | {, J1, 90, No Symbol, Q1, 90, Fog 112 | |, J2, 91, TNC Stream Sw, Q2, 91, TNC Stream SW 113 | }, J3, 92, No Symbol, Q3, 92, No Symbol 114 | ~, J4, 93, TNC Stream Sw, Q4, 93, TNC Stream SW 115 | ` 116 | 117 | var symbolGlyphs = map[string]string{} 118 | 119 | func init() { 120 | primarySymbolMap = map[byte]string{} 121 | alternateSymbolMap = map[byte]string{} 122 | 123 | r := bufio.NewReader(strings.NewReader(symbolTableTextFile)) 124 | 125 | for { 126 | l, err := r.ReadString(byte('\n')) 127 | if err != nil { 128 | if err != io.EOF { 129 | log.Fatalf("Error reading a line: %v", err) 130 | } 131 | break 132 | } 133 | l = strings.TrimSpace(l) 134 | if len(l) < 3 { 135 | continue 136 | } 137 | parts := strings.Split(l[2:], ",") 138 | if len(parts) >= 6 { 139 | for i, p := range parts { 140 | parts[i] = strings.TrimSpace(p) 141 | } 142 | if len(parts) > 7 { 143 | symbolGlyphs[parts[2]] = parts[6] 144 | symbolGlyphs[parts[5]] = parts[7] 145 | } 146 | primarySymbolMap[l[0]] = parts[2] 147 | alternateSymbolMap[l[0]] = parts[5] 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /type.go: -------------------------------------------------------------------------------- 1 | package aprs 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // PacketType represents the type of information contains in an APRS packet. 8 | type PacketType byte 9 | 10 | var packetTypeNames = map[byte]string{ 11 | 0x1c: "Current Mic-E Data (Rev 0 beta)", 12 | 0x1d: "Old Mic-E Data (Rev 0 beta)", 13 | '!': "Position without timestamp (no APRS messaging), or Ultimeter 2000 WX Station", 14 | '#': "Peet Bros U-II Weather Station", 15 | '$': "Raw GPS data or Ultimeter 2000", 16 | '%': "Agrelo DFJr / MicroFinder", 17 | '"': "Old Mic-E Data (but Current data for TM-D700)", 18 | ')': "Item", 19 | '*': "Peet Bros U-II Weather Station", 20 | ',': "Invalid data or test data", 21 | '/': "Position with timestamp (no APRS messaging)", 22 | ':': "Message", 23 | ';': "Object", 24 | '<': "Station Capabilities", 25 | '=': "Position without timestamp (with APRS messaging)", 26 | '>': "Status", 27 | '?': "Query", 28 | '@': "Position with timestamp (with APRS messaging)", 29 | 'T': "Telemetry data", 30 | '[': "Maidenhead grid locator beacon (obsolete)", 31 | '_': "Weather Report (without position)", 32 | '`': "Current Mic-E Data (not used in TM-D700)", 33 | '{': "User-Defined APRS packet format", 34 | '}': "Third-party traffic", 35 | } 36 | 37 | // IsMessage is true if this PacketType represents a message. 38 | func (p PacketType) IsMessage() bool { 39 | return p == ':' 40 | } 41 | 42 | // IsThirdParty is true if this PacketType is sent via third party. 43 | func (p PacketType) IsThirdParty() bool { 44 | return p == '}' 45 | } 46 | 47 | func (p PacketType) String() string { 48 | if t, ok := packetTypeNames[byte(p)]; ok { 49 | return t 50 | } 51 | return fmt.Sprintf("Unknown %x", byte(p)) 52 | } 53 | --------------------------------------------------------------------------------