Initial import
This commit is contained in:
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
module git.maze.io/go/ham
|
||||||
|
|
||||||
|
go 1.25.6
|
||||||
|
|
||||||
|
replace git.maze.io/go/ham/internal/configuration => ./internal/configuration
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
git.maze.io/go/ham/internal/configuration v0.0.0-00010101000000-000000000000 // indirect
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
)
|
||||||
19
go.sum
Normal file
19
go.sum
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
153
internal/collector/client.go
Normal file
153
internal/collector/client.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Brokers []string `yaml:"brokers" env:"MQTT_BROKERS"`
|
||||||
|
ClientID string `yaml:"client_id" env:"MQTT_CLIENT_ID"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
Protocol string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceConfig struct {
|
||||||
|
Callsign string `yaml:"callsign" env:"DEVICE_CALLSIGN"`
|
||||||
|
Device string `yaml:"device" env:"DEVICE"`
|
||||||
|
Manufacturer string `yaml:"manufacturer" env:"DEVICE_MANUFACTURER"`
|
||||||
|
RXFrequency float64 `yaml:"rx_frequency" env:"DEVICE_RX_FREQ"` // in Hz
|
||||||
|
RXGain float64 `yaml:"rx_gain" env:"DEVICE_RX_GAIN"` // in dBm
|
||||||
|
TXFrequency float64 `yaml:"tx_frequency" env:"DEVICE_TX_FREQ"` // in Hz
|
||||||
|
TXPower float64 `yaml:"tx_power" env:"DEVICE_TX_GAIN"` // in dBm
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
client mqtt.Client
|
||||||
|
options *Options
|
||||||
|
topicCollector string
|
||||||
|
topicDevice string
|
||||||
|
topicPacket string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(options *Options) (*Client, error) {
|
||||||
|
if options.ClientID == "" {
|
||||||
|
options.ClientID, _ = os.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := mqtt.NewClientOptions()
|
||||||
|
opts.SetCredentialsProvider(func() (username, password string) {
|
||||||
|
return options.Username, options.Password
|
||||||
|
})
|
||||||
|
opts.SetClientID(options.ClientID)
|
||||||
|
|
||||||
|
if len(options.Brokers) == 0 {
|
||||||
|
log.Println("collector: no brokers configured, using localhost")
|
||||||
|
opts.AddBroker("localhost:1883")
|
||||||
|
} else {
|
||||||
|
for _, broker := range options.Brokers {
|
||||||
|
log.Printf("collector: adding broker at tcp://%s", broker)
|
||||||
|
opts.AddBroker(broker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.Protocol == "" {
|
||||||
|
options.Protocol = "unspec"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
options: options,
|
||||||
|
topicCollector: "collector/" + options.ClientID,
|
||||||
|
topicDevice: "device",
|
||||||
|
topicPacket: "packet/" + options.Protocol,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last will and testament.
|
||||||
|
opts.SetWill(client.topicCollector+"/status", client.collectorStatus("offline"), 1, true)
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
opts.OnConnect = func(c mqtt.Client) {
|
||||||
|
log.Println("collector: connected to MQTT broker")
|
||||||
|
token := c.Publish(client.topicCollector+"/status", 1, false, client.collectorStatus("online"))
|
||||||
|
if token.Wait() && token.Error() != nil {
|
||||||
|
log.Printf("collector: failed to signal collector online: %v", token.Error())
|
||||||
|
c.Disconnect(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts.OnConnectionLost = func(c mqtt.Client, err error) {
|
||||||
|
log.Printf("collector: disconnected from MQTT broker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to broker(s).
|
||||||
|
client.client = mqtt.NewClient(opts)
|
||||||
|
token := client.client.Connect()
|
||||||
|
if token.Wait() && token.Error() != nil {
|
||||||
|
return nil, token.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) collectorStatus(status string) string {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"time": time.Now().UTC(),
|
||||||
|
"id": c.options.ClientID,
|
||||||
|
"protocol": c.options.Protocol,
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) deviceStatus(config *DeviceConfig, status string) string {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"time": time.Now().UTC(),
|
||||||
|
"protocol": c.options.Protocol,
|
||||||
|
"callsign": config.Callsign,
|
||||||
|
"device": config.Device,
|
||||||
|
"manufacturer": config.Manufacturer,
|
||||||
|
"rx_freq": config.RXFrequency,
|
||||||
|
"rx_gain": config.RXGain,
|
||||||
|
"tx_freq": config.TXFrequency,
|
||||||
|
"tx_power": config.TXPower,
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeviceOnline(config *DeviceConfig) error {
|
||||||
|
return c.setDeviceStatus(config, "online")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeviceOffline(config *DeviceConfig) error {
|
||||||
|
return c.setDeviceStatus(config, "offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setDeviceStatus(config *DeviceConfig, status string) error {
|
||||||
|
log.Printf("collector: device %s/%s is %s", c.options.ClientID, config.Callsign, status)
|
||||||
|
token := c.client.Publish(c.topicDevice+"/"+config.Callsign, 1, false, c.deviceStatus(config, status))
|
||||||
|
return token.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) PublishPacket(id string, packet any) error {
|
||||||
|
var payload string
|
||||||
|
switch packet := packet.(type) {
|
||||||
|
case string:
|
||||||
|
payload = packet
|
||||||
|
case []byte:
|
||||||
|
payload = string(packet)
|
||||||
|
default:
|
||||||
|
b, err := json.Marshal(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload = string(b)
|
||||||
|
}
|
||||||
|
token := c.client.Publish(c.topicPacket, 0, false, payload)
|
||||||
|
return token.Error()
|
||||||
|
}
|
||||||
106
protocol/aprs/address.go
Normal file
106
protocol/aprs/address.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package aprs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAddressInvalid = errors.New(`aprs: invalid address`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
|
Call string `json:"call"`
|
||||||
|
SSID string `json:"ssid,omitempty"`
|
||||||
|
IsRepeated bool `json:"is_repeated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Address) EqualTo(b Address) bool {
|
||||||
|
return a.Call == b.Call && a.SSID == b.SSID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Address) String() string {
|
||||||
|
var r = ""
|
||||||
|
|
||||||
|
if a.IsRepeated {
|
||||||
|
r = "*"
|
||||||
|
}
|
||||||
|
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 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
type Path []Address
|
||||||
|
|
||||||
|
func (p Path) String() string {
|
||||||
|
var s = make([]string, len(p))
|
||||||
|
for i, a := range p {
|
||||||
|
s[i] = a.String()
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return as, nil
|
||||||
|
}
|
||||||
29
protocol/aprs/base91.go
Normal file
29
protocol/aprs/base91.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package aprs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return 0, fmt.Errorf("aprs: invalid base-91 encoding char %q (%d)", c, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
n *= 91
|
||||||
|
n += int(c) - 33
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func base91Encode(n int) string {
|
||||||
|
var s []string
|
||||||
|
for n > 0 {
|
||||||
|
c := n % 91
|
||||||
|
n /= 91
|
||||||
|
s = append([]string{string(byte(c) + 33)}, s...)
|
||||||
|
}
|
||||||
|
return strings.Join(s, "")
|
||||||
|
}
|
||||||
41
protocol/aprs/datatype.go
Normal file
41
protocol/aprs/datatype.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
2
protocol/aprs/doc.go
Normal file
2
protocol/aprs/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package aprs implements Automatic Packet Reporting System message parsing.
|
||||||
|
package aprs
|
||||||
481
protocol/aprs/packet.go
Normal file
481
protocol/aprs/packet.go
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
package aprs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidPacket signals a corrupted/unknown APRS packet.
|
||||||
|
ErrInvalidPacket = errors.New("aprs: invalid packet")
|
||||||
|
|
||||||
|
// ErrInvalidPosition signals a corrupted APRS position report.
|
||||||
|
ErrInvalidPosition = errors.New("aprs: invalid position")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Payload is the raw payload contained within an APRS packet.
|
||||||
|
type Payload string
|
||||||
|
|
||||||
|
// Type of payload.
|
||||||
|
func (p Payload) Type() DataType {
|
||||||
|
var t DataType
|
||||||
|
|
||||||
|
if len(p) > 0 {
|
||||||
|
t = DataType(p[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(b byte) bool {
|
||||||
|
return b >= '0' && b <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Payload) Len() int { return len(p) }
|
||||||
|
|
||||||
|
// Velocity details.
|
||||||
|
type Velocity struct {
|
||||||
|
Course float64 // Degrees
|
||||||
|
Speed float64 // Knots
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wind details.
|
||||||
|
type Wind struct {
|
||||||
|
Direction float64 // Degrees
|
||||||
|
Speed float64 // Knots
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerHeightGain details.
|
||||||
|
type PowerHeightGain struct {
|
||||||
|
PowerCode byte
|
||||||
|
HeightCode byte
|
||||||
|
GainCode byte
|
||||||
|
DirectivityCode byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power level (in Watts).
|
||||||
|
func (p PowerHeightGain) Power() int {
|
||||||
|
w := int(p.PowerCode - '0')
|
||||||
|
if w <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return w * w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height above ground (in meters).
|
||||||
|
func (p PowerHeightGain) Height() float64 {
|
||||||
|
h := float64(p.HeightCode - '0')
|
||||||
|
if h <= 0 {
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
return math.Pow(2, h) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gain level (in dBs).
|
||||||
|
func (p PowerHeightGain) Gain() int {
|
||||||
|
d := int(p.GainCode - '0')
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directivity angle.
|
||||||
|
func (p PowerHeightGain) Directivity() float64 {
|
||||||
|
d := int(p.DirectivityCode - '0')
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(d%8) * 45.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// OmniDFStrength contains the omni-directional direction finding signal strength (for fox hunting).
|
||||||
|
type OmniDFStrength struct {
|
||||||
|
StrengthCode byte
|
||||||
|
HeightCode byte
|
||||||
|
GainCode byte
|
||||||
|
DirectivityCode byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strength of the signal.
|
||||||
|
func (o OmniDFStrength) Strength() int {
|
||||||
|
w := int(o.StrengthCode - '0')
|
||||||
|
if w <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return w * w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height above ground (in meters).
|
||||||
|
func (o OmniDFStrength) Height() float64 {
|
||||||
|
h := float64(o.HeightCode - '0')
|
||||||
|
if h <= 0 {
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
return math.Pow(2, h) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gain level (in dBs).
|
||||||
|
func (o OmniDFStrength) Gain() int {
|
||||||
|
d := int(o.GainCode - '0')
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directivity angle.
|
||||||
|
func (o OmniDFStrength) Directivity() float64 {
|
||||||
|
d := int(o.DirectivityCode - '0')
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(d%8) * 45.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packet contains an APRS packet.
|
||||||
|
type Packet struct {
|
||||||
|
// Raw packet (as captured from the air or APRS-IS).
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
|
||||||
|
// Src is the source address.
|
||||||
|
Src Address `json:"src"`
|
||||||
|
|
||||||
|
// Dst is the destination address.
|
||||||
|
Dst Address `json:"dst"`
|
||||||
|
|
||||||
|
// Path contains the digipeater path.
|
||||||
|
Path Path `json:"path,omitempty"`
|
||||||
|
|
||||||
|
// Payload is the raw payload.
|
||||||
|
Payload Payload `json:"payload"`
|
||||||
|
|
||||||
|
// Position encoded in the payload.
|
||||||
|
Position *Position `json:"position,omitempty"`
|
||||||
|
|
||||||
|
// Time encoded in the payload.
|
||||||
|
Time *time.Time `json:"time,omitempty"`
|
||||||
|
|
||||||
|
// Altitude encoded in the payload (in feet).
|
||||||
|
Altitude float64 `json:"altitude,omitempty"`
|
||||||
|
|
||||||
|
// Velocity encoded in the payload.
|
||||||
|
Velocity *Velocity `json:"velocity,omitempty"`
|
||||||
|
|
||||||
|
// Wind details encoded in the payload.
|
||||||
|
Wind *Wind `json:"wind,omitempty"`
|
||||||
|
|
||||||
|
// PHG are the power, height and gain details encoded in the payload.
|
||||||
|
PHG *PowerHeightGain `json:"phg,omitempty"`
|
||||||
|
|
||||||
|
// DFS are the direction finder strength details encoded in the payload.
|
||||||
|
DFS *OmniDFStrength `json:"dfs,omitempty"`
|
||||||
|
|
||||||
|
// Range encoded in the payload (in miles).
|
||||||
|
Range float64 `json:"range,omitempty"`
|
||||||
|
|
||||||
|
// Symbol encoded in the payload.
|
||||||
|
Symbol Symbol `json:"symbol"`
|
||||||
|
|
||||||
|
// Comment encoded in the payload.
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
|
||||||
|
// Unparsed data.
|
||||||
|
data string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePacket parses an APRS packet as captured from AX.25 or APRS-IS.
|
||||||
|
func ParsePacket(raw string) (Packet, error) {
|
||||||
|
p := Packet{Raw: raw}
|
||||||
|
|
||||||
|
var i int
|
||||||
|
if i = strings.Index(raw, ":"); i < 0 {
|
||||||
|
return p, ErrInvalidPacket
|
||||||
|
}
|
||||||
|
p.Payload = Payload(raw[i+1:])
|
||||||
|
|
||||||
|
// Parse src, dst and path
|
||||||
|
var err error
|
||||||
|
var a = raw[:i]
|
||||||
|
if i = strings.Index(a, ">"); i < 0 {
|
||||||
|
return p, ErrInvalidPacket
|
||||||
|
}
|
||||||
|
if p.Src, err = ParseAddress(a[:i]); err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
var r = strings.Split(a[i+1:], ",")
|
||||||
|
if p.Dst, err = ParseAddress(r[0]); err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
if p.Path, err = ParsePath(strings.Join(r[1:], ",")); err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post processing of payload
|
||||||
|
err = p.parse()
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) parse() error {
|
||||||
|
s := string(p.Payload)
|
||||||
|
//log.Printf("parse %q [%c]\n", s, p.Payload.Type())
|
||||||
|
|
||||||
|
switch p.Payload.Type() {
|
||||||
|
case '!': // Lat/Long Position Report Format — without Timestamp
|
||||||
|
var o = strings.IndexByte(s, '!')
|
||||||
|
pos, txt, err := ParsePosition(s[o+1:], !isDigit(s[o+1]))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Position = &pos
|
||||||
|
p.data = txt
|
||||||
|
if len(s) >= 20 {
|
||||||
|
p.Symbol[0] = s[9]
|
||||||
|
p.Symbol[1] = s[19]
|
||||||
|
}
|
||||||
|
case '=':
|
||||||
|
compressed := IsValidCompressedSymTable(s[1])
|
||||||
|
pos, txt, err := ParsePosition(s[1:], compressed)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Position = &pos
|
||||||
|
p.data = txt
|
||||||
|
if compressed {
|
||||||
|
p.Symbol[0] = s[1]
|
||||||
|
p.Symbol[1] = s[10]
|
||||||
|
} else {
|
||||||
|
p.Symbol[0] = s[9]
|
||||||
|
p.Symbol[1] = s[19]
|
||||||
|
}
|
||||||
|
case '/', '@': // Lat/Long Position Report Format — with Timestamp
|
||||||
|
if len(s) < 8 {
|
||||||
|
return ErrInvalidPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
var compressed bool
|
||||||
|
if s[7] == 'h' || s[7] == 'z' || s[7] == '/' {
|
||||||
|
if ts, err := ParseTime(s[1:]); err == nil {
|
||||||
|
p.Time = &ts
|
||||||
|
}
|
||||||
|
compressed = IsValidCompressedSymTable(s[8])
|
||||||
|
pos, txt, err := ParsePosition(s[8:], compressed)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Position = &pos
|
||||||
|
p.data = txt
|
||||||
|
} else if s[7] >= '0' && s[7] <= '9' {
|
||||||
|
ts, err := ParseTime(s[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Time = &ts
|
||||||
|
compressed = IsValidCompressedSymTable(s[10])
|
||||||
|
pos, txt, err := ParsePosition(s[10:], compressed)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Position = &pos
|
||||||
|
p.data = txt
|
||||||
|
}
|
||||||
|
if compressed {
|
||||||
|
p.Symbol[0] = s[8]
|
||||||
|
p.Symbol[1] = s[17]
|
||||||
|
} else {
|
||||||
|
p.Symbol[0] = s[16]
|
||||||
|
p.Symbol[1] = s[26]
|
||||||
|
}
|
||||||
|
case ';':
|
||||||
|
pos, txt, err := ParsePosition(s[18:], !isDigit(s[18]))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Position = &pos
|
||||||
|
p.data = txt
|
||||||
|
case '[':
|
||||||
|
pos, txt, err := ParsePositionGrid(s[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Position = &pos
|
||||||
|
p.data = txt
|
||||||
|
case '`', '\'':
|
||||||
|
pos, err := ParseMicE(s, p.Dst.Call)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Position = &pos
|
||||||
|
p.parseMicEData()
|
||||||
|
|
||||||
|
return nil // there is no additional data to parse
|
||||||
|
default:
|
||||||
|
pos, txt, err := ParsePositionBoth(s)
|
||||||
|
if err != nil {
|
||||||
|
if err != ErrInvalidPosition {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Comment = s[1:]
|
||||||
|
} else {
|
||||||
|
p.Position = &pos
|
||||||
|
p.Comment = txt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Position != nil {
|
||||||
|
if p.Position.Compressed {
|
||||||
|
return p.parseCompressedData()
|
||||||
|
}
|
||||||
|
return p.parseData()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) parseMicEData() error {
|
||||||
|
// APRS PROTOCOL REFERENCE 1.0.1 Chapter 10, page 42 in PDF
|
||||||
|
|
||||||
|
s := string(p.Payload)
|
||||||
|
|
||||||
|
// Mic-E Message Type
|
||||||
|
var mt []string
|
||||||
|
var t string
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
mc := miceCodes[rune(p.Dst.Call[i])][1]
|
||||||
|
if strings.HasSuffix(mc, "(Custom)") {
|
||||||
|
t = messageTypeCustom
|
||||||
|
} else if strings.HasSuffix(mc, "(Std)") {
|
||||||
|
t = messageTypeStd
|
||||||
|
}
|
||||||
|
mt = append(mt, string(mc[0]))
|
||||||
|
}
|
||||||
|
switch t {
|
||||||
|
case messageTypeStd:
|
||||||
|
mt = append(mt, " (Std)")
|
||||||
|
case messageTypeCustom:
|
||||||
|
mt = append(mt, " (Custom)")
|
||||||
|
}
|
||||||
|
p.Comment = miceMsgTypes[strings.Join(mt, "")]
|
||||||
|
|
||||||
|
// Speed and Course.
|
||||||
|
speed := float64(int(s[4])-28) * 10
|
||||||
|
dc := float64(int(s[5])-28) / 10
|
||||||
|
unit := float64(int(dc))
|
||||||
|
speed += unit
|
||||||
|
course := dc - unit
|
||||||
|
course += float64(int(s[6]) - 28)
|
||||||
|
if speed >= 800 {
|
||||||
|
speed -= 800
|
||||||
|
}
|
||||||
|
speed = 1.852 * speed // convert speed from knots to km/h
|
||||||
|
if course >= 400 {
|
||||||
|
course -= 400
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbol
|
||||||
|
p.Symbol[0] = s[7]
|
||||||
|
p.Symbol[1] = s[8]
|
||||||
|
|
||||||
|
p.Comment += fmt.Sprintf(" (%.fkm/h, %.f°)", speed, course)
|
||||||
|
|
||||||
|
// Check whether there's additional Telemetry or Status Text data.
|
||||||
|
if len(s) == 9 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s[9] == ',' || s[9] == '\x1d' {
|
||||||
|
// TODO: Parse telemetry data.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse MicE Status Text data.
|
||||||
|
// TODO: Parse additional data in the Status Text data:
|
||||||
|
// - Actual (custom) text message
|
||||||
|
// - Maidenhead locator
|
||||||
|
// - Altitude
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) parseCompressedData() error {
|
||||||
|
// Parse csT bytes
|
||||||
|
if len(p.data) >= 3 {
|
||||||
|
// Compression Type (T) Byte Format
|
||||||
|
// Bit: 7 | 6 | 5 | 4 3 | 2 1 0 |
|
||||||
|
// -------+--------+---------+-------------+------------------+
|
||||||
|
// Unused | Unused | GPS Fix | NMEA Source | Origin |
|
||||||
|
// -------+--------+---------+-------------+------------------+
|
||||||
|
// Val: 0 | 0 | 0 = old | 00 = other | 000 = Compressed |
|
||||||
|
// | | 1 = cur | 01 = GLL | 001 = TNC BTex |
|
||||||
|
// | | | 10 = CGA | 010 = Software |
|
||||||
|
// | | | 11 = RMC | 011 = [tbd] |
|
||||||
|
// | | | | 100 = KPC3 |
|
||||||
|
// | | | | 101 = Pico |
|
||||||
|
// | | | | 110 = Other |
|
||||||
|
// | | | | 111 = Digipeater |
|
||||||
|
cb := p.data[0] - 33
|
||||||
|
sb := p.data[1] - 33
|
||||||
|
Tb := p.data[2] - 33
|
||||||
|
if p.data[0] != ' ' && ((Tb>>3)&3) == 2 {
|
||||||
|
// CGA sentence, NMEA Source = 0b10
|
||||||
|
d, err := base91Decode(p.data[0:2])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Altitude = math.Pow(1.002, float64(d))
|
||||||
|
p.Comment = p.data[3:]
|
||||||
|
} else if cb <= 89 { // !..z
|
||||||
|
// Course/Speed
|
||||||
|
p.Velocity.Course = float64(cb) * 4.0
|
||||||
|
p.Velocity.Speed = math.Pow(1.08, float64(sb)) - 1.0
|
||||||
|
} else if cb == 90 { // {
|
||||||
|
// Pre-Calculated Radio Range
|
||||||
|
p.Range = 2 * math.Pow(1.08, float64(sb))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) parseData() error {
|
||||||
|
switch {
|
||||||
|
case len(p.data) >= 1 && p.data[0] == ' ':
|
||||||
|
p.Comment = p.data[1:]
|
||||||
|
|
||||||
|
case len(p.data) >= 7 && strings.HasPrefix(p.data, "PHG"):
|
||||||
|
p.PHG.PowerCode = p.data[3]
|
||||||
|
p.PHG.HeightCode = p.data[4]
|
||||||
|
p.PHG.GainCode = p.data[5]
|
||||||
|
p.PHG.DirectivityCode = p.data[6]
|
||||||
|
p.Range = math.Sqrt(2 * p.PHG.Height() * math.Sqrt((float64(p.PHG.Power())/10)*(float64(p.PHG.Gain())/2)))
|
||||||
|
p.Comment = p.data[7:]
|
||||||
|
|
||||||
|
case len(p.data) >= 7 && strings.HasPrefix(p.data, "RNG"):
|
||||||
|
var err error
|
||||||
|
p.Range, err = strconv.ParseFloat(p.data[3:7], 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Comment = p.data[7:]
|
||||||
|
|
||||||
|
case len(p.data) >= 7 && strings.HasPrefix(p.data, "DFS"):
|
||||||
|
p.DFS.StrengthCode = p.data[3]
|
||||||
|
p.DFS.HeightCode = p.data[4]
|
||||||
|
p.DFS.GainCode = p.data[5]
|
||||||
|
p.DFS.DirectivityCode = p.data[6]
|
||||||
|
p.Comment = p.data[7:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Payload) Time() (time.Time, error) {
|
||||||
|
switch p.Type() {
|
||||||
|
case '/', '@':
|
||||||
|
return ParseTime(string(p)[1:])
|
||||||
|
default:
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
329
protocol/aprs/packet_test.go
Normal file
329
protocol/aprs/packet_test.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
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[<P'PC>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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
313
protocol/aprs/position.go
Normal file
313
protocol/aprs/position.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package aprs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.maze.io/go/ham/util/maidenhead"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ParseUncompressedPosition(s string) (Position, string, error) {
|
||||||
|
// APRS PROTOCOL REFERENCE 1.0.1 Chapter 8, page 32 (42 in PDF)
|
||||||
|
|
||||||
|
pos := Position{}
|
||||||
|
|
||||||
|
if len(s) < 18 {
|
||||||
|
return pos, "", ErrInvalidPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
b := []byte(s)
|
||||||
|
for _, p := range disambiguation {
|
||||||
|
if b[p] == ' ' {
|
||||||
|
pos.Ambiguity++
|
||||||
|
b[p] = '0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s = string(b)
|
||||||
|
|
||||||
|
var (
|
||||||
|
err error
|
||||||
|
latDeg, latMin, latMinFrag uint64
|
||||||
|
lngDeg, lngMin, lngMinFrag uint64
|
||||||
|
latHemi, lngHemi byte
|
||||||
|
isSouth, isWest bool
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var lat, lng int
|
||||||
|
if lat, err = base91Decode(s[1:5]); err != nil {
|
||||||
|
return pos, "", err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := Position{}
|
||||||
|
if o == 2 || o == 4 || o == 6 || o == 8 {
|
||||||
|
p, err := maidenhead.ParseLocator(s[:o])
|
||||||
|
if err != nil {
|
||||||
|
return pos, "", err
|
||||||
|
}
|
||||||
|
pos.Latitude = p.Latitude
|
||||||
|
pos.Longitude = p.Longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
var txt string
|
||||||
|
if o < len(s) {
|
||||||
|
txt = s[o+1:]
|
||||||
|
}
|
||||||
|
return pos, txt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParsePosition(s string, compressed bool) (Position, string, error) {
|
||||||
|
if compressed {
|
||||||
|
return ParseCompressedPosition(s)
|
||||||
|
}
|
||||||
|
return ParseUncompressedPosition(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParsePositionBoth(s string) (Position, string, error) {
|
||||||
|
pos, txt, err := ParseUncompressedPosition(s)
|
||||||
|
if err != nil {
|
||||||
|
return ParseCompressedPosition(s)
|
||||||
|
}
|
||||||
|
return pos, txt, err
|
||||||
|
}
|
||||||
266
protocol/aprs/symbol.go
Normal file
266
protocol/aprs/symbol.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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"},
|
||||||
|
}
|
||||||
|
)
|
||||||
33
protocol/aprs/time.go
Normal file
33
protocol/aprs/time.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package aprs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeFormatError struct {
|
||||||
|
Time string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err TimeFormatError) Error() string {
|
||||||
|
return fmt.Sprintf("aprs: unknown time stamp %q", err.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTime(s string) (time.Time, error) {
|
||||||
|
if len(s) < 7 {
|
||||||
|
return time.Time{}, TimeFormatError{s}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case s[6] == 'z': // Day/Hours/Minutes (DHM) format
|
||||||
|
return time.Parse("021504", s[:6])
|
||||||
|
case s[6] == '/': // Day/Hours/Minutes (DHM) format
|
||||||
|
return time.Parse("021504", s[:6])
|
||||||
|
case s[6] == 'h': // Hours/Minutes/Seconds (HMS) format
|
||||||
|
return time.Parse("150405", s[:6])
|
||||||
|
case len(s) >= 8: // Month/Day/Hours/Minutes (MDHM) format
|
||||||
|
return time.Parse("01021504", s[:8])
|
||||||
|
default:
|
||||||
|
return time.Time{}, TimeFormatError{s}
|
||||||
|
}
|
||||||
|
}
|
||||||
372
protocol/meshcore/crypto/ed25519.go
Normal file
372
protocol/meshcore/crypto/ed25519.go
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"filippo.io/edwards25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
seedSize = 32
|
||||||
|
publicKeySize = 32
|
||||||
|
privateKeySize = seedSize + publicKeySize
|
||||||
|
signatureSize = 64
|
||||||
|
sha512Size = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type PrivateKey struct {
|
||||||
|
seed [seedSize]byte
|
||||||
|
pub [publicKeySize]byte
|
||||||
|
s edwards25519.Scalar
|
||||||
|
prefix [sha512Size / 2]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (priv *PrivateKey) Bytes() []byte {
|
||||||
|
k := make([]byte, 0, privateKeySize)
|
||||||
|
k = append(k, priv.seed[:]...)
|
||||||
|
k = append(k, priv.pub[:]...)
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func (priv *PrivateKey) HexString() string {
|
||||||
|
return hex.EncodeToString(priv.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (priv *PrivateKey) Seed() []byte {
|
||||||
|
seed := priv.seed
|
||||||
|
return seed[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (priv *PrivateKey) PublicKey() []byte {
|
||||||
|
pub := priv.pub
|
||||||
|
return pub[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicKey struct {
|
||||||
|
a edwards25519.Point
|
||||||
|
aBytes [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pub *PublicKey) Bytes() []byte {
|
||||||
|
a := pub.aBytes
|
||||||
|
return a[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pub *PublicKey) String() string {
|
||||||
|
return hex.EncodeToString(pub.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pub *PublicKey) EqualTo(other *PublicKey) bool {
|
||||||
|
if pub == nil || other == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Equal(pub.aBytes[:], other.aBytes[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pub *PublicKey) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(hex.EncodeToString(pub.Bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pub *PublicKey) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k, err := NewPublicKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pub.a = k.a
|
||||||
|
copy(pub.aBytes[:], k.aBytes[:])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKey generates a new Ed25519 private key pair.
|
||||||
|
func GenerateKey() (*PublicKey, *PrivateKey, error) {
|
||||||
|
priv := &PrivateKey{}
|
||||||
|
key, err := generateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pub, err := NewPublicKey(priv.PublicKey())
|
||||||
|
return pub, key, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateKey(priv *PrivateKey) (*PrivateKey, error) {
|
||||||
|
rand.Read(priv.seed[:])
|
||||||
|
precomputePrivateKey(priv)
|
||||||
|
return priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPrivateKeyFromSeed(seed []byte) (*PrivateKey, error) {
|
||||||
|
priv := &PrivateKey{}
|
||||||
|
return newPrivateKeyFromSeed(priv, seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPrivateKeyFromSeed(priv *PrivateKey, seed []byte) (*PrivateKey, error) {
|
||||||
|
if l := len(seed); l != seedSize {
|
||||||
|
return nil, errors.New("ed25519: bad seed length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
copy(priv.seed[:], seed)
|
||||||
|
precomputePrivateKey(priv)
|
||||||
|
return priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func precomputePrivateKey(priv *PrivateKey) {
|
||||||
|
hs := sha512.New()
|
||||||
|
hs.Write(priv.seed[:])
|
||||||
|
h := hs.Sum(make([]byte, 0, sha512Size))
|
||||||
|
|
||||||
|
s, err := priv.s.SetBytesWithClamping(h[:32])
|
||||||
|
if err != nil {
|
||||||
|
panic("ed25519: internal error: setting scalar failed")
|
||||||
|
}
|
||||||
|
A := (&edwards25519.Point{}).ScalarBaseMult(s)
|
||||||
|
copy(priv.pub[:], A.Bytes())
|
||||||
|
|
||||||
|
copy(priv.prefix[:], h[32:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPrivateKey(priv []byte) (*PrivateKey, error) {
|
||||||
|
p := &PrivateKey{}
|
||||||
|
return newPrivateKey(p, priv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPrivateKey(priv *PrivateKey, privBytes []byte) (*PrivateKey, error) {
|
||||||
|
if l := len(privBytes); l != privateKeySize {
|
||||||
|
return nil, errors.New("ed25519: bad private key length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(priv.seed[:], privBytes[:32])
|
||||||
|
|
||||||
|
hs := sha512.New()
|
||||||
|
hs.Write(priv.seed[:])
|
||||||
|
h := hs.Sum(make([]byte, 0, sha512Size))
|
||||||
|
|
||||||
|
if _, err := priv.s.SetBytesWithClamping(h[:32]); err != nil {
|
||||||
|
panic("ed25519: internal error: setting scalar failed")
|
||||||
|
}
|
||||||
|
// Note that we are not decompressing the public key point here,
|
||||||
|
// because it takes > 20% of the time of a signature generation.
|
||||||
|
// Signing doesn't use it as a point anyway.
|
||||||
|
copy(priv.pub[:], privBytes[32:])
|
||||||
|
|
||||||
|
copy(priv.prefix[:], h[32:])
|
||||||
|
|
||||||
|
return priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodePublicKey(s string) (*PublicKey, error) {
|
||||||
|
var (
|
||||||
|
pub []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch len(s) {
|
||||||
|
case hex.EncodedLen(32):
|
||||||
|
pub, err = hex.DecodeString(s)
|
||||||
|
case base64.RawStdEncoding.EncodedLen(32):
|
||||||
|
pub, err = base64.RawStdEncoding.DecodeString(s)
|
||||||
|
case base64.StdEncoding.DecodedLen(32):
|
||||||
|
pub, err = base64.StdEncoding.DecodeString(s)
|
||||||
|
default:
|
||||||
|
return nil, errors.New("auth: invalid public key")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewPublicKey(pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublicKey(pub []byte) (*PublicKey, error) {
|
||||||
|
p := &PublicKey{}
|
||||||
|
return newPublicKey(p, pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPublicKey(pub *PublicKey, pubBytes []byte) (*PublicKey, error) {
|
||||||
|
if l := len(pubBytes); l != publicKeySize {
|
||||||
|
return nil, errors.New("ed25519: bad public key length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
// SetBytes checks that the point is on the curve.
|
||||||
|
if _, err := pub.a.SetBytes(pubBytes); err != nil {
|
||||||
|
return nil, errors.New("ed25519: bad public key")
|
||||||
|
}
|
||||||
|
copy(pub.aBytes[:], pubBytes)
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain separation prefixes used to disambiguate Ed25519/Ed25519ph/Ed25519ctx.
|
||||||
|
// See RFC 8032, Section 2 and Section 5.1.
|
||||||
|
const (
|
||||||
|
// domPrefixPure is empty for pure Ed25519.
|
||||||
|
domPrefixPure = ""
|
||||||
|
// domPrefixPh is dom2(phflag=1) for Ed25519ph. It must be followed by the
|
||||||
|
// uint8-length prefixed context.
|
||||||
|
domPrefixPh = "SigEd25519 no Ed25519 collisions\x01"
|
||||||
|
// domPrefixCtx is dom2(phflag=0) for Ed25519ctx. It must be followed by the
|
||||||
|
// uint8-length prefixed context.
|
||||||
|
domPrefixCtx = "SigEd25519 no Ed25519 collisions\x00"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Sign(priv *PrivateKey, message []byte) []byte {
|
||||||
|
// Outline the function body so that the returned signature can be
|
||||||
|
// stack-allocated.
|
||||||
|
signature := make([]byte, signatureSize)
|
||||||
|
return sign(signature, priv, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(signature []byte, priv *PrivateKey, message []byte) []byte {
|
||||||
|
return signWithDom(signature, priv, message, domPrefixPure, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SignPH(priv *PrivateKey, message []byte, context string) ([]byte, error) {
|
||||||
|
// Outline the function body so that the returned signature can be
|
||||||
|
// stack-allocated.
|
||||||
|
signature := make([]byte, signatureSize)
|
||||||
|
return signPH(signature, priv, message, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signPH(signature []byte, priv *PrivateKey, message []byte, context string) ([]byte, error) {
|
||||||
|
if l := len(message); l != sha512Size {
|
||||||
|
return nil, errors.New("ed25519: bad Ed25519ph message hash length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
if l := len(context); l > 255 {
|
||||||
|
return nil, errors.New("ed25519: bad Ed25519ph context length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
return signWithDom(signature, priv, message, domPrefixPh, context), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SignCtx(priv *PrivateKey, message []byte, context string) ([]byte, error) {
|
||||||
|
// Outline the function body so that the returned signature can be
|
||||||
|
// stack-allocated.
|
||||||
|
signature := make([]byte, signatureSize)
|
||||||
|
return signCtx(signature, priv, message, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signCtx(signature []byte, priv *PrivateKey, message []byte, context string) ([]byte, error) {
|
||||||
|
// FIPS 186-5 specifies Ed25519 and Ed25519ph (with context), but not Ed25519ctx.
|
||||||
|
// Note that per RFC 8032, Section 5.1, the context SHOULD NOT be empty.
|
||||||
|
if l := len(context); l > 255 {
|
||||||
|
return nil, errors.New("ed25519: bad Ed25519ctx context length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
return signWithDom(signature, priv, message, domPrefixCtx, context), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signWithDom(signature []byte, priv *PrivateKey, message []byte, domPrefix, context string) []byte {
|
||||||
|
mh := sha512.New()
|
||||||
|
if domPrefix != domPrefixPure {
|
||||||
|
mh.Write([]byte(domPrefix))
|
||||||
|
mh.Write([]byte{byte(len(context))})
|
||||||
|
mh.Write([]byte(context))
|
||||||
|
}
|
||||||
|
mh.Write(priv.prefix[:])
|
||||||
|
mh.Write(message)
|
||||||
|
messageDigest := make([]byte, 0, sha512Size)
|
||||||
|
messageDigest = mh.Sum(messageDigest)
|
||||||
|
r, err := edwards25519.NewScalar().SetUniformBytes(messageDigest)
|
||||||
|
if err != nil {
|
||||||
|
panic("ed25519: internal error: setting scalar failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
R := (&edwards25519.Point{}).ScalarBaseMult(r)
|
||||||
|
|
||||||
|
kh := sha512.New()
|
||||||
|
if domPrefix != domPrefixPure {
|
||||||
|
kh.Write([]byte(domPrefix))
|
||||||
|
kh.Write([]byte{byte(len(context))})
|
||||||
|
kh.Write([]byte(context))
|
||||||
|
}
|
||||||
|
kh.Write(R.Bytes())
|
||||||
|
kh.Write(priv.pub[:])
|
||||||
|
kh.Write(message)
|
||||||
|
hramDigest := make([]byte, 0, sha512Size)
|
||||||
|
hramDigest = kh.Sum(hramDigest)
|
||||||
|
k, err := edwards25519.NewScalar().SetUniformBytes(hramDigest)
|
||||||
|
if err != nil {
|
||||||
|
panic("ed25519: internal error: setting scalar failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
S := edwards25519.NewScalar().MultiplyAdd(k, &priv.s, r)
|
||||||
|
|
||||||
|
copy(signature[:32], R.Bytes())
|
||||||
|
copy(signature[32:], S.Bytes())
|
||||||
|
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
|
func Verify(pub *PublicKey, message, sig []byte) error {
|
||||||
|
return verify(pub, message, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verify(pub *PublicKey, message, sig []byte) error {
|
||||||
|
return verifyWithDom(pub, message, sig, domPrefixPure, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyPH(pub *PublicKey, message []byte, sig []byte, context string) error {
|
||||||
|
if l := len(message); l != sha512Size {
|
||||||
|
return errors.New("ed25519: bad Ed25519ph message hash length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
if l := len(context); l > 255 {
|
||||||
|
return errors.New("ed25519: bad Ed25519ph context length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
return verifyWithDom(pub, message, sig, domPrefixPh, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyCtx(pub *PublicKey, message []byte, sig []byte, context string) error {
|
||||||
|
if l := len(context); l > 255 {
|
||||||
|
return errors.New("ed25519: bad Ed25519ctx context length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
return verifyWithDom(pub, message, sig, domPrefixCtx, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyWithDom(pub *PublicKey, message, sig []byte, domPrefix, context string) error {
|
||||||
|
if l := len(sig); l != signatureSize {
|
||||||
|
return errors.New("ed25519: bad signature length: " + strconv.Itoa(l))
|
||||||
|
}
|
||||||
|
|
||||||
|
if sig[63]&224 != 0 {
|
||||||
|
return errors.New("ed25519: invalid signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
kh := sha512.New()
|
||||||
|
if domPrefix != domPrefixPure {
|
||||||
|
kh.Write([]byte(domPrefix))
|
||||||
|
kh.Write([]byte{byte(len(context))})
|
||||||
|
kh.Write([]byte(context))
|
||||||
|
}
|
||||||
|
kh.Write(sig[:32])
|
||||||
|
kh.Write(pub.aBytes[:])
|
||||||
|
kh.Write(message)
|
||||||
|
hramDigest := make([]byte, 0, sha512Size)
|
||||||
|
hramDigest = kh.Sum(hramDigest)
|
||||||
|
k, err := edwards25519.NewScalar().SetUniformBytes(hramDigest)
|
||||||
|
if err != nil {
|
||||||
|
panic("ed25519: internal error: setting scalar failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
S, err := edwards25519.NewScalar().SetCanonicalBytes(sig[32:])
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("ed25519: invalid signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// [S]B = R + [k]A --> [k](-A) + [S]B = R
|
||||||
|
minusA := (&edwards25519.Point{}).Negate(&pub.a)
|
||||||
|
R := (&edwards25519.Point{}).VarTimeDoubleScalarBaseMult(k, minusA, S)
|
||||||
|
|
||||||
|
if !bytes.Equal(sig[:32], R.Bytes()) {
|
||||||
|
return errors.New("ed25519: invalid signature")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
15
protocol/meshcore/crypto/ed25519_test.go
Normal file
15
protocol/meshcore/crypto/ed25519_test.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKey(t *testing.T) {
|
||||||
|
pub, key, err := GenerateKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("key: %s", key.HexString())
|
||||||
|
t.Logf("pub: %s", pub)
|
||||||
|
}
|
||||||
10
util/maidenhead/doc.go
Normal file
10
util/maidenhead/doc.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Package maidenhead implements the maidenhead locator system.
|
||||||
|
//
|
||||||
|
// The Maidenhead Locator System (a.k.a. QTH Locator and IARU Locator) is a geocode system used
|
||||||
|
// by amateur radio operators to succinctly describe their geographic coordinates, which replaced
|
||||||
|
// the deprecated QRA locator, which was limited to European contacts.
|
||||||
|
//
|
||||||
|
// Maidenhead locators are also commonly referred to as QTH locators, grid locators or grid
|
||||||
|
// squares, although the "squares" are distorted on any non-equirectangular cartographic
|
||||||
|
// projection.
|
||||||
|
package maidenhead
|
||||||
148
util/maidenhead/maidenhead.go
Normal file
148
util/maidenhead/maidenhead.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// Package maidenhead implements the Maidenhead Locator System, a geographic
|
||||||
|
// coordinate system used by amataur radio (HAM) operators.
|
||||||
|
package maidenhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Precision of the computed locator.
|
||||||
|
const (
|
||||||
|
FieldPrecision = iota + 1
|
||||||
|
SquarePrecision
|
||||||
|
SubSquarePrecision
|
||||||
|
ExtendedSquarePrecision
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
upper = "ABCDEFGHIJKLMNOPQRSTUVWX"
|
||||||
|
lower = "abcdefghijklmnopqrstuvwx"
|
||||||
|
digit = "0123456789"
|
||||||
|
)
|
||||||
|
|
||||||
|
// locator computes the Maidenhead Locator for a given position.
|
||||||
|
func locator(p Point, precision int) (string, error) {
|
||||||
|
if math.IsNaN(p.Latitude) {
|
||||||
|
return "", errors.New("maidenhead: latitude is not a digit")
|
||||||
|
}
|
||||||
|
if math.IsInf(p.Latitude, 0) {
|
||||||
|
return "", errors.New("maidenhead: latitude is infinite")
|
||||||
|
}
|
||||||
|
if math.IsNaN(p.Longitude) {
|
||||||
|
return "", errors.New("maidenhead: longitude is not a digit")
|
||||||
|
}
|
||||||
|
if math.IsInf(p.Longitude, 0) {
|
||||||
|
return "", errors.New("maidenhead: longitude is infinite")
|
||||||
|
}
|
||||||
|
if math.Abs(p.Latitude) == 90.0 {
|
||||||
|
return "", errors.New("maidenhead: grid square invalid at poles")
|
||||||
|
} else if math.Abs(p.Latitude) > 90.0 {
|
||||||
|
return "", fmt.Errorf("maidenhead: invalid latitude %.04f", p.Latitude)
|
||||||
|
} else if math.Abs(p.Longitude) > 180.0 {
|
||||||
|
return "", fmt.Errorf("maidenhead: invalid longitude %.05f", p.Longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lat = p.Latitude + 90.0
|
||||||
|
lng = p.Longitude + 180.0
|
||||||
|
loc string
|
||||||
|
)
|
||||||
|
|
||||||
|
lat = lat/10.0 + 0.0000001
|
||||||
|
lng = lng/20.0 + 0.0000001
|
||||||
|
loc = loc + string(upper[int(lng)]) + string(upper[int(lat)])
|
||||||
|
if precision == 1 {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
lat = 10 * (lat - math.Floor(lat))
|
||||||
|
lng = 10 * (lng - math.Floor(lng))
|
||||||
|
loc = loc + fmt.Sprintf("%d%d", int(lng)%10, int(lat)%10)
|
||||||
|
if precision == 2 {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
lat = 24 * (lat - math.Floor(lat))
|
||||||
|
lng = 24 * (lng - math.Floor(lng))
|
||||||
|
loc = loc + string(upper[int(lng)]) + string(upper[int(lat)])
|
||||||
|
if precision == 3 {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
lat = 10 * (lat - math.Floor(lat))
|
||||||
|
lng = 10 * (lng - math.Floor(lng))
|
||||||
|
loc = loc + fmt.Sprintf("%d%d", int(lng)%10, int(lat)%10)
|
||||||
|
if precision == 4 {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
lat = 24 * (lat - math.Floor(lat))
|
||||||
|
lng = 24 * (lng - math.Floor(lng))
|
||||||
|
loc = loc + string(lower[int(lng)]) + string(lower[int(lat)])
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var parseLocatorMult = []struct {
|
||||||
|
s, p string
|
||||||
|
mult float64
|
||||||
|
}{
|
||||||
|
{upper[:18], lower[:18], 20.0},
|
||||||
|
{upper[:18], lower[:18], 10.0},
|
||||||
|
{digit[:10], digit[:10], 20.0 / 10.0},
|
||||||
|
{digit[:10], digit[:10], 10.0 / 10.0},
|
||||||
|
{upper[:24], lower[:24], 20.0 / (10.0 * 24.0)},
|
||||||
|
{upper[:24], lower[:24], 10.0 / (10.0 * 24.0)},
|
||||||
|
{digit[:10], digit[:10], 20.0 / (10.0 * 24.0 * 10.0)},
|
||||||
|
{digit[:10], digit[:10], 10.0 / (10.0 * 24.0 * 10.0)},
|
||||||
|
{lower[:24], lower[:24], 20.0 / (10.0 * 24.0 * 10.0 * 24.0)},
|
||||||
|
{lower[:24], lower[:24], 10.0 / (10.0 * 24.0 * 10.0 * 24.0)},
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxLocatorLength = len(parseLocatorMult)
|
||||||
|
|
||||||
|
func parseLocator(locator string, strict bool, centered bool) (point Point, err error) {
|
||||||
|
var (
|
||||||
|
lnglat = [2]float64{
|
||||||
|
-180.0,
|
||||||
|
-90.0,
|
||||||
|
}
|
||||||
|
i, j int
|
||||||
|
char rune
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(locator) > maxLocatorLength {
|
||||||
|
err = fmt.Errorf("maidenhead: locator is too long (%d characters, maximum %d characters allowed)",
|
||||||
|
len(locator), maxLocatorLength)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(locator)%2 != 0 {
|
||||||
|
err = fmt.Errorf("maidenhead: locator has odd number of characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strict {
|
||||||
|
for i, char = range locator {
|
||||||
|
if j = strings.Index(parseLocatorMult[i].s, string(char)); j < 0 {
|
||||||
|
err = fmt.Errorf("maidenhead: invalid character at offset %d", i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lnglat[i%2] += float64(j) * parseLocatorMult[i].mult
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i, char = range strings.ToLower(locator) {
|
||||||
|
if j = strings.Index(parseLocatorMult[i].p, string(char)); j < 0 {
|
||||||
|
err = fmt.Errorf("maidenhead: invalid character at offset %d", i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lnglat[i%2] += float64(j) * parseLocatorMult[i].mult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if centered {
|
||||||
|
lnglat[0] += parseLocatorMult[i-1].mult / 2.0
|
||||||
|
lnglat[1] += parseLocatorMult[i].mult / 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
point = NewPoint(lnglat[1], lnglat[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
142
util/maidenhead/maidenhead_test.go
Normal file
142
util/maidenhead/maidenhead_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package maidenhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tests = []struct {
|
||||||
|
point Point
|
||||||
|
loc string
|
||||||
|
loc4 string
|
||||||
|
}{
|
||||||
|
{Point{48.14666, 11.60833}, "JN58TD", "JN58TD25"},
|
||||||
|
{Point{-34.91, -56.21166}, "GF15VC", "GF15VC41"},
|
||||||
|
{Point{38.92, -77.065}, "FM18LW", "FM18LW20"},
|
||||||
|
{Point{-41.28333, 174.745}, "RE78IR", "RE78IR92"},
|
||||||
|
{Point{41.714775, -72.727260}, "FN31PR", "FN31PR21"},
|
||||||
|
{Point{37.413708, -122.1073236}, "CM87WJ", "CM87WJ79"},
|
||||||
|
{Point{35.0542, -85.1142}, "EM75KB", "EM75KB63"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGridSquare(t *testing.T) {
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.loc, func(t *testing.T) {
|
||||||
|
enc, err := test.point.GridSquare()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if enc != test.loc {
|
||||||
|
t.Fatalf("%s want %q, got %q\n", test.point, test.loc, enc)
|
||||||
|
}
|
||||||
|
//t.Logf("%s encoded to %q\n", test.point, enc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtendedSquarePrecision(t *testing.T) {
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.loc4, func(t *testing.T) {
|
||||||
|
got, err := test.point.Locator(ExtendedSquarePrecision)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != test.loc4 {
|
||||||
|
t.Fatalf("%s want %q, got %q\n", test.point, test.loc4, got)
|
||||||
|
}
|
||||||
|
//t.Logf("%s encoded to %q\n", test.point, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsed locator must be translated to the same locator
|
||||||
|
// using GridSquare()
|
||||||
|
func TestParseLocator(t *testing.T) {
|
||||||
|
var locTests = map[string]Point{
|
||||||
|
"JN88RT": Point{48.791667, 17.416667},
|
||||||
|
"JN89HF": Point{49.208333, 16.583333},
|
||||||
|
"JN58TD": Point{48.125000, 11.583333},
|
||||||
|
"GF15VC": Point{-34.916667, -56.250000},
|
||||||
|
"FM18LW": Point{38.916667, -77.083333},
|
||||||
|
"RE78IR": Point{-41.291667, 174.666667},
|
||||||
|
"PM45lm": Point{35.5, 128.916667},
|
||||||
|
}
|
||||||
|
|
||||||
|
for loc, p := range locTests {
|
||||||
|
t.Run(loc, func(t *testing.T) {
|
||||||
|
point, err := ParseLocator(loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s parsing error: %s", loc, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := point.GridSquare()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v to GridSquare(): %s", loc, point, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.EqualFold(l, loc) {
|
||||||
|
t.Errorf("%s: parsed to %v produces %s\n", loc, point, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(almostEqual(p.Latitude, point.Latitude) && almostEqual(p.Longitude, point.Longitude)) {
|
||||||
|
t.Errorf("%s: at %s, expeted %s", loc, point, p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func almostEqual(a, b float64) bool {
|
||||||
|
return math.Abs(a-b) < 1e-06
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid Maiden Head locators must return error
|
||||||
|
func TestParseInvalidLocatorStrict(t *testing.T) {
|
||||||
|
locs := []string{
|
||||||
|
"JN58td",
|
||||||
|
"JN58TDAA",
|
||||||
|
"JNH",
|
||||||
|
"QN58jh",
|
||||||
|
"JN77ya",
|
||||||
|
" ",
|
||||||
|
"JN55J",
|
||||||
|
"JN89HA11aa2",
|
||||||
|
"JN89HA11aa22",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range locs {
|
||||||
|
t.Run(l, func(t *testing.T) {
|
||||||
|
_, err := ParseLocatorStrict(l)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Parsing invalid locator '%s' with ParseLocatorStrict() doesn't return any error", l)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance between corner point and center of the locator square
|
||||||
|
func TestParseLocatorCentered(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
loc string
|
||||||
|
distExpected float64
|
||||||
|
}{
|
||||||
|
{"JN89", 91.42870273454076},
|
||||||
|
{"JN89HF", 3.8111046375990782},
|
||||||
|
{"JN89HF23", 0.38109528459829756},
|
||||||
|
{"JN89HF23ag", 0.015878904160500258},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.loc, func(t *testing.T) {
|
||||||
|
p, _ := ParseLocator(test.loc)
|
||||||
|
pc, _ := ParseLocatorCentered(test.loc)
|
||||||
|
|
||||||
|
dist := pc.Distance(p)
|
||||||
|
|
||||||
|
if !almostEqual(dist, test.distExpected) {
|
||||||
|
t.Errorf("Distance between the center and corner of square locator '%s' is %g, expected %g",
|
||||||
|
test.loc, dist, test.distExpected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
162
util/maidenhead/point.go
Normal file
162
util/maidenhead/point.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package maidenhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Earth radius
|
||||||
|
r = 6371
|
||||||
|
)
|
||||||
|
|
||||||
|
var compassBearing = []struct {
|
||||||
|
label string
|
||||||
|
start, ended float64
|
||||||
|
}{
|
||||||
|
{"N", 000.00, 011.25}, {"NNE", 011.25, 033.75}, {"NE", 033.75, 056.25}, {"ENE", 056.25, 078.75},
|
||||||
|
{"E", 078.75, 101.25}, {"ESE", 101.25, 123.75}, {"SE", 123.75, 146.25}, {"SSE", 146.25, 168.75},
|
||||||
|
{"S", 168.75, 191.25}, {"SSW", 191.25, 213.75}, {"SW", 213.75, 236.25}, {"WSW", 236.25, 258.75},
|
||||||
|
{"W", 258.75, 281.25}, {"WNW", 281.25, 303.75}, {"NW", 303.75, 326.25}, {"NNW", 326.25, 348.75},
|
||||||
|
{"N", 348.75, 360.00},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point is a geographical point on the map.
|
||||||
|
type Point struct {
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPoint returns a new Point structure with given latitude and longitude.
|
||||||
|
func NewPoint(latitude, longitude float64) Point {
|
||||||
|
return Point{latitude, longitude}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLocator parses a Maidenhead Locator with permissive rule matching.
|
||||||
|
func ParseLocator(locator string) (Point, error) {
|
||||||
|
return parseLocator(locator, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLocatorStrict parses a Maidenhead Locator with strict rule matching.
|
||||||
|
func ParseLocatorStrict(locator string) (Point, error) {
|
||||||
|
return parseLocator(locator, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLocatorCentered parses a Maidenhead Locator with permissive rule matching.
|
||||||
|
// Returns Points structure with coordinates of the square center
|
||||||
|
func ParseLocatorCentered(locator string) (Point, error) {
|
||||||
|
return parseLocator(locator, false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLocatorStrictCentered parses a Maidenhead Locator with strict rule matching.
|
||||||
|
// Returns Points structure with coordinates of the square center
|
||||||
|
func ParseLocatorStrictCentered(locator string) (Point, error) {
|
||||||
|
return parseLocator(locator, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EqualTo returns true if the coordinates point to the same geographical location.
|
||||||
|
func (p Point) EqualTo(other Point) bool {
|
||||||
|
var (
|
||||||
|
dlat = p.Latitude - other.Latitude
|
||||||
|
dlng = p.Longitude - other.Longitude
|
||||||
|
)
|
||||||
|
|
||||||
|
for dlat < -180.0 {
|
||||||
|
dlat += 360.0
|
||||||
|
}
|
||||||
|
for dlat > 180.0 {
|
||||||
|
dlat -= 360.0
|
||||||
|
}
|
||||||
|
for dlng < -90.0 {
|
||||||
|
dlng += 90.0
|
||||||
|
}
|
||||||
|
for dlng > 90.0 {
|
||||||
|
dlng -= 90.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return dlat == 0.0 && dlng == 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearing calculates the (approximate) bearing to another heading.
|
||||||
|
func (p Point) Bearing(heading Point) float64 {
|
||||||
|
var (
|
||||||
|
hn = p.Latitude / 180 * math.Pi
|
||||||
|
he = p.Longitude / 180 * math.Pi
|
||||||
|
n = heading.Latitude / 180 * math.Pi
|
||||||
|
e = heading.Longitude / 180 * math.Pi
|
||||||
|
co = math.Cos(he-e)*math.Cos(hn)*math.Cos(n) + math.Sin(hn)*math.Sin(n)
|
||||||
|
ca = math.Atan(math.Abs(math.Sqrt(1-co*co) / co))
|
||||||
|
)
|
||||||
|
|
||||||
|
if co < 0.0 {
|
||||||
|
ca = math.Pi - ca
|
||||||
|
}
|
||||||
|
|
||||||
|
var si = math.Sin(e-he) * math.Cos(n) * math.Cos(hn)
|
||||||
|
co = math.Sin(n) - math.Sin(hn)*math.Cos(ca)
|
||||||
|
var az = math.Atan(math.Abs(si / co))
|
||||||
|
if co < 0.0 {
|
||||||
|
az = math.Pi - az
|
||||||
|
}
|
||||||
|
if si < 0.0 {
|
||||||
|
az = -az
|
||||||
|
}
|
||||||
|
if az < 0.0 {
|
||||||
|
az = az + 2.0*math.Pi
|
||||||
|
}
|
||||||
|
return az * 180 / math.Pi
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompassBearing returns the compass bearing to a heading.
|
||||||
|
func (p Point) CompassBearing(heading Point) string {
|
||||||
|
bearing := p.Bearing(heading)
|
||||||
|
for bearing < 0.0 {
|
||||||
|
bearing += 360.0
|
||||||
|
}
|
||||||
|
for bearing > 360.0 {
|
||||||
|
bearing -= 360.0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, compass := range compassBearing {
|
||||||
|
if bearing >= compass.start && bearing <= compass.ended {
|
||||||
|
return compass.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should never reach
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance calculates the (approximate) distance to another point in km.
|
||||||
|
func (p Point) Distance(other Point) float64 {
|
||||||
|
var (
|
||||||
|
hn = p.Latitude / 180 * math.Pi
|
||||||
|
he = p.Longitude / 180 * math.Pi
|
||||||
|
n = other.Latitude / 180 * math.Pi
|
||||||
|
e = other.Longitude / 180 * math.Pi
|
||||||
|
co = math.Cos(he-e)*math.Cos(hn)*math.Cos(n) + math.Sin(hn)*math.Sin(n)
|
||||||
|
ca = math.Atan(math.Abs(math.Sqrt(1-co*co) / co))
|
||||||
|
)
|
||||||
|
|
||||||
|
if co < 0.0 {
|
||||||
|
ca = math.Pi - ca
|
||||||
|
}
|
||||||
|
|
||||||
|
return r * ca
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridSquare returns a Maidenhead Locator for the point coordinates.
|
||||||
|
func (p Point) GridSquare() (string, error) {
|
||||||
|
return locator(p, SubSquarePrecision)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locator returns a Maidenhead Locator for the point coordinates with
|
||||||
|
// specified precision
|
||||||
|
func (p Point) Locator(precision int) (string, error) {
|
||||||
|
return locator(p, precision)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a stringified Point structure.
|
||||||
|
func (p Point) String() string {
|
||||||
|
return fmt.Sprintf("Point(%f, %f)", p.Latitude, p.Longitude)
|
||||||
|
}
|
||||||
37
util/maidenhead/point_test.go
Normal file
37
util/maidenhead/point_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package maidenhead
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pointTests = []struct {
|
||||||
|
point Point
|
||||||
|
bearing float64
|
||||||
|
compass string
|
||||||
|
}{
|
||||||
|
{Point{48.14666, 11.60833}, 195, "SSW"},
|
||||||
|
{Point{-34.91, -56.21166}, 69, "ENE"},
|
||||||
|
{Point{38.92, -77.065}, 98, "E"},
|
||||||
|
{Point{-41.28333, 174.745}, 187, "S"},
|
||||||
|
{Point{41.714775, -72.727260}, 101, "ESE"},
|
||||||
|
{Point{37.413708, -122.1073236}, 69, "ENE"},
|
||||||
|
{Point{35.0542, -85.1142}, 92, "E"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPointBearing(t *testing.T) {
|
||||||
|
var center = NewPoint(0, 0)
|
||||||
|
for _, test := range pointTests {
|
||||||
|
t.Run("", func(t *testing.T) {
|
||||||
|
bearing := math.Floor(test.point.Bearing(center))
|
||||||
|
if bearing != test.bearing {
|
||||||
|
t.Fatalf("%s -> %s, expected %0.f, got %0.f\n", test.point, center, test.bearing, bearing)
|
||||||
|
}
|
||||||
|
compass := test.point.CompassBearing(center)
|
||||||
|
if compass != test.compass {
|
||||||
|
t.Logf("%s -> %s, expected %q, got %q\n", test.point, center, test.compass, compass)
|
||||||
|
}
|
||||||
|
//t.Logf("%s -> %s, bearing %.0f %s\n", test.point, center, bearing, compass)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user