mirror of
https://github.com/la5nta/pat
synced 2026-08-13 17:46:54 -04:00
714 lines
18 KiB
Go
714 lines
18 KiB
Go
// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved.
|
|
// Use of this source code is governed by the MIT-license that can be
|
|
// found in the LICENSE file.
|
|
|
|
// A portable Winlink client for amateur radio email.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/la5nta/pat/cfg"
|
|
"github.com/la5nta/pat/internal/buildinfo"
|
|
"github.com/la5nta/pat/internal/debug"
|
|
"github.com/la5nta/pat/internal/directories"
|
|
"github.com/la5nta/pat/internal/forms"
|
|
"github.com/la5nta/pat/internal/gpsd"
|
|
|
|
"github.com/la5nta/wl2k-go/catalog"
|
|
"github.com/la5nta/wl2k-go/fbb"
|
|
"github.com/la5nta/wl2k-go/mailbox"
|
|
"github.com/la5nta/wl2k-go/rigcontrol/hamlib"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
const (
|
|
MethodArdop = "ardop"
|
|
MethodTelnet = "telnet"
|
|
MethodPactor = "pactor"
|
|
MethodVaraHF = "varahf"
|
|
MethodVaraFM = "varafm"
|
|
|
|
MethodAX25 = "ax25"
|
|
MethodAX25AGWPE = MethodAX25 + "+agwpe"
|
|
MethodAX25Linux = MethodAX25 + "+linux"
|
|
MethodAX25SerialTNC = MethodAX25 + "+serial-tnc"
|
|
|
|
// TODO: Remove after some release cycles (2023-05-21)
|
|
MethodSerialTNCDeprecated = "serial-tnc"
|
|
)
|
|
|
|
var commands = []Command{
|
|
{
|
|
Str: "connect",
|
|
Desc: "Connect to a remote station.",
|
|
HandleFunc: connectHandle,
|
|
Usage: UsageConnect,
|
|
Example: ExampleConnect,
|
|
MayConnect: true,
|
|
},
|
|
{
|
|
Str: "interactive",
|
|
Desc: "Run interactive mode.",
|
|
Usage: "[options]",
|
|
Options: map[string]string{
|
|
"--http, -h": "Start http server for web UI in the background.",
|
|
},
|
|
HandleFunc: InteractiveHandle,
|
|
MayConnect: true,
|
|
LongLived: true,
|
|
},
|
|
{
|
|
Str: "http",
|
|
Desc: "Run http server for web UI.",
|
|
Usage: "[options]",
|
|
Options: map[string]string{
|
|
"--addr, -a": "Listen address. Default is :8080.",
|
|
},
|
|
HandleFunc: httpHandle,
|
|
MayConnect: true,
|
|
LongLived: true,
|
|
},
|
|
{
|
|
Str: "compose",
|
|
Desc: "Compose a new message.",
|
|
Usage: "[options]\n" +
|
|
"\tIf no options are passed, composes interactively.\n" +
|
|
"\tIf options are passed, reads message from stdin similar to mail(1).",
|
|
Options: map[string]string{
|
|
"--from, -r": "Address to send from. Default is your call from config or --mycall, but can be specified to use tactical addresses.",
|
|
"--subject, -s": "Subject",
|
|
"--attachment , -a": "Attachment path (may be repeated)",
|
|
"--cc, -c": "CC Address(es) (may be repeated)",
|
|
"--p2p-only": "Send over peer to peer links only (avoid CMS)",
|
|
"": "Recipient address (may be repeated)",
|
|
},
|
|
HandleFunc: composeMessage,
|
|
},
|
|
{
|
|
Str: "read",
|
|
Desc: "Read messages.",
|
|
HandleFunc: func(ctx context.Context, args []string) {
|
|
readMail(ctx)
|
|
},
|
|
},
|
|
{
|
|
Str: "composeform",
|
|
Aliases: []string{"formPath"},
|
|
Desc: "Post form-based report.",
|
|
Usage: "[options]",
|
|
Options: map[string]string{
|
|
"--template": "path to the form template file. Uses the --forms directory as root. Defaults to 'ICS USA Forms/ICS213.txt'",
|
|
},
|
|
HandleFunc: composeFormReport,
|
|
},
|
|
{
|
|
Str: "position",
|
|
Aliases: []string{"pos"},
|
|
Desc: "Post a position report (GPSd or manual entry).",
|
|
Usage: "[options]",
|
|
Options: map[string]string{
|
|
"--latlon": "latitude,longitude in decimal degrees for manual entry. Will use GPSd if this is empty.",
|
|
"--comment, -c": "Comment to be included in the position report.",
|
|
},
|
|
Example: ExamplePosition,
|
|
HandleFunc: posReportHandle,
|
|
},
|
|
{
|
|
Str: "extract",
|
|
Desc: "Extract attachments from a message file.",
|
|
Usage: "file",
|
|
HandleFunc: extractMessageHandle,
|
|
},
|
|
{
|
|
Str: "rmslist",
|
|
Desc: "Print/search in list of RMS nodes.",
|
|
Usage: "[options] [search term]",
|
|
Options: map[string]string{
|
|
"--mode, -m": "Mode filter.",
|
|
"--band, -b": "Band filter (e.g. '80m').",
|
|
"--force-download, -d": "Force download of latest list from winlink.org.",
|
|
"--sort-distance, -s": "Sort by distance",
|
|
},
|
|
HandleFunc: rmsListHandle,
|
|
},
|
|
{
|
|
Str: "updateforms",
|
|
Desc: "Download the latest form templates from winlink.org.",
|
|
HandleFunc: func(ctx context.Context, args []string) {
|
|
if _, err := formsMgr.UpdateFormTemplates(ctx); err != nil {
|
|
log.Printf("%v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
Str: "configure",
|
|
Desc: "Open configuration file for editing.",
|
|
HandleFunc: configureHandle,
|
|
},
|
|
{
|
|
Str: "version",
|
|
Desc: "Print the application version.",
|
|
HandleFunc: func(_ context.Context, args []string) {
|
|
fmt.Printf("%s %s\n", buildinfo.AppName, buildinfo.VersionString())
|
|
},
|
|
},
|
|
{
|
|
Str: "env",
|
|
Desc: "List environment variables.",
|
|
HandleFunc: envHandle,
|
|
},
|
|
{
|
|
Str: "help",
|
|
Desc: "Print detailed help for a given command.",
|
|
// Avoid initialization loop by invoking helpHandler in main
|
|
},
|
|
}
|
|
|
|
var (
|
|
config cfg.Config
|
|
rigs map[string]hamlib.VFO
|
|
logWriter io.Writer
|
|
eventLog *EventLogger
|
|
|
|
exchangeChan chan ex // The channel that the exchange loop is listening on
|
|
exchangeConn net.Conn // Pointer to the active session connection (exchange)
|
|
mbox *mailbox.DirHandler // The mailbox
|
|
listenHub *ListenerHub
|
|
promptHub *PromptHub
|
|
formsMgr *forms.Manager
|
|
)
|
|
|
|
var fOptions struct {
|
|
IgnoreBusy bool // Move to connect?
|
|
SendOnly bool // Move to connect?
|
|
RadioOnly bool
|
|
|
|
Robust bool
|
|
MyCall string
|
|
Listen string
|
|
MailboxPath string
|
|
ConfigPath string
|
|
LogPath string
|
|
EventLogPath string
|
|
FormsPath string
|
|
}
|
|
|
|
func optionsSet() *pflag.FlagSet {
|
|
set := pflag.NewFlagSet("options", pflag.ExitOnError)
|
|
|
|
set.StringVar(&fOptions.MyCall, "mycall", "", "Your callsign (winlink user).")
|
|
set.StringVarP(&fOptions.Listen, "listen", "l", "", "Comma-separated list of methods to listen on (e.g. ardop,telnet,ax25).")
|
|
set.BoolVarP(&fOptions.SendOnly, "send-only", "s", false, "Download inbound messages later, send only.")
|
|
set.BoolVarP(&fOptions.RadioOnly, "radio-only", "", false, "Radio Only mode (Winlink Hybrid RMS only).")
|
|
set.BoolVar(&fOptions.IgnoreBusy, "ignore-busy", false, "Don't wait for clear channel before connecting to a node.")
|
|
|
|
defaultMBox := filepath.Join(directories.DataDir(), "mailbox")
|
|
defaultFormsPath := filepath.Join(directories.DataDir(), "Standard_Forms")
|
|
defaultConfigPath := filepath.Join(directories.ConfigDir(), "config.json")
|
|
defaultLogPath := filepath.Join(directories.StateDir(), strings.ToLower(buildinfo.AppName+".log"))
|
|
defaultEventLogPath := filepath.Join(directories.StateDir(), "eventlog.json")
|
|
set.StringVar(&fOptions.MailboxPath, "mbox", defaultMBox, "Path to mailbox directory.")
|
|
set.StringVar(&fOptions.FormsPath, "forms", defaultFormsPath, "Path to forms directory.")
|
|
set.StringVar(&fOptions.ConfigPath, "config", defaultConfigPath, "Path to config file.")
|
|
set.StringVar(&fOptions.LogPath, "log", defaultLogPath, "Path to log file. The file is truncated on each startup.")
|
|
set.StringVar(&fOptions.EventLogPath, "event-log", defaultEventLogPath, "Path to event log file.")
|
|
|
|
return set
|
|
}
|
|
|
|
func init() {
|
|
listenHub = NewListenerHub()
|
|
promptHub = NewPromptHub()
|
|
|
|
pflag.Usage = func() {
|
|
fmt.Fprintf(os.Stderr, "%s is a client for the Winlink 2000 Network.\n\n", buildinfo.AppName)
|
|
fmt.Fprintf(os.Stderr, "Usage:\n %s [options] command [arguments]\n", os.Args[0])
|
|
|
|
fmt.Fprintln(os.Stderr, "\nCommands:")
|
|
for _, cmd := range commands {
|
|
fmt.Fprintf(os.Stderr, " %-15s %s\n", cmd.Str, cmd.Desc)
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "\nOptions:")
|
|
optionsSet().PrintDefaults()
|
|
fmt.Fprint(os.Stderr, "\n")
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
cmd, args := parseFlags(os.Args)
|
|
|
|
debug.Printf("Version: %s", buildinfo.VersionString())
|
|
debug.Printf("Command: %s %v", cmd.Str, args)
|
|
|
|
fOptions.MailboxPath = filepath.Clean(fOptions.MailboxPath)
|
|
fOptions.FormsPath = filepath.Clean(fOptions.FormsPath)
|
|
fOptions.ConfigPath = filepath.Clean(fOptions.ConfigPath)
|
|
fOptions.LogPath = filepath.Clean(fOptions.LogPath)
|
|
fOptions.EventLogPath = filepath.Clean(fOptions.EventLogPath)
|
|
debug.Printf("Mailbox dir is\t'%s'", fOptions.MailboxPath)
|
|
debug.Printf("Forms dir is\t'%s'", fOptions.FormsPath)
|
|
debug.Printf("Config file is\t'%s'", fOptions.ConfigPath)
|
|
debug.Printf("Log file is \t'%s'", fOptions.LogPath)
|
|
debug.Printf("Event log file is\t'%s'", fOptions.EventLogPath)
|
|
directories.MigrateLegacyDataDir()
|
|
|
|
// Graceful shutdown by cancelling background context on interrupt.
|
|
//
|
|
// If we have an active connection, cancel that instead.
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() {
|
|
dirtyDisconnectNext := false // So we can do a dirty disconnect on the second interrupt
|
|
for {
|
|
<-sig
|
|
if ok := abortActiveConnection(dirtyDisconnectNext); ok {
|
|
dirtyDisconnectNext = !dirtyDisconnectNext
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
cancel()
|
|
}()
|
|
|
|
// Skip initialization for some commands
|
|
switch cmd.Str {
|
|
case "help":
|
|
helpHandle(args)
|
|
return
|
|
case "configure", "version":
|
|
cmd.HandleFunc(ctx, args)
|
|
return
|
|
}
|
|
|
|
// Enable the GZIP extension experiment by default
|
|
if _, ok := os.LookupEnv("GZIP_EXPERIMENT"); !ok {
|
|
os.Setenv("GZIP_EXPERIMENT", "1")
|
|
}
|
|
|
|
// Parse configuration file
|
|
var err error
|
|
config, err = LoadConfig(fOptions.ConfigPath, cfg.DefaultConfig)
|
|
if err != nil {
|
|
log.Fatalf("Unable to load/write config: %s", err)
|
|
}
|
|
|
|
// Initialize logger
|
|
f, err := os.Create(fOptions.LogPath)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
logWriter = io.MultiWriter(f, os.Stdout)
|
|
log.SetOutput(logWriter)
|
|
eventLog, err = NewEventLogger(fOptions.EventLogPath)
|
|
if err != nil {
|
|
log.Fatal("Unable to open event log file:", err)
|
|
}
|
|
|
|
// Read command line options from config if unset
|
|
if fOptions.MyCall == "" && config.MyCall == "" {
|
|
fmt.Fprint(os.Stderr, "Missing mycall\n")
|
|
os.Exit(1)
|
|
} else if fOptions.MyCall == "" {
|
|
fOptions.MyCall = config.MyCall
|
|
}
|
|
|
|
// Ensure mycall is all upper case.
|
|
fOptions.MyCall = strings.ToUpper(fOptions.MyCall)
|
|
|
|
// Don't use config password if we don't use config mycall
|
|
if !strings.EqualFold(fOptions.MyCall, config.MyCall) {
|
|
config.SecureLoginPassword = ""
|
|
}
|
|
|
|
// Replace placeholders in connect aliases
|
|
for k, v := range config.ConnectAliases {
|
|
config.ConnectAliases[k] = strings.ReplaceAll(v, cfg.PlaceholderMycall, fOptions.MyCall)
|
|
}
|
|
|
|
if fOptions.Listen == "" && len(config.Listen) > 0 {
|
|
fOptions.Listen = strings.Join(config.Listen, ",")
|
|
}
|
|
|
|
// init forms subsystem
|
|
formsMgr = forms.NewManager(forms.Config{
|
|
FormsPath: fOptions.FormsPath,
|
|
MyCall: fOptions.MyCall,
|
|
Locator: config.Locator,
|
|
AppVersion: buildinfo.VersionStringShort(),
|
|
UserAgent: buildinfo.UserAgent(),
|
|
LineReader: readLine,
|
|
GPSd: config.GPSd,
|
|
})
|
|
|
|
// Make sure we clean up on exit, closing any open resources etc.
|
|
defer cleanup()
|
|
|
|
// Load the mailbox handler
|
|
loadMBox()
|
|
|
|
if cmd.MayConnect {
|
|
rigs = loadHamlibRigs()
|
|
exchangeChan = exchangeLoop()
|
|
|
|
go func() {
|
|
if config.VersionReportingDisabled {
|
|
return
|
|
}
|
|
|
|
for { // Check every 6 hours, but it won't post more frequent than 24h.
|
|
postVersionUpdate() // Ignore errors
|
|
time.Sleep(6 * time.Hour)
|
|
}
|
|
}()
|
|
}
|
|
|
|
if cmd.LongLived {
|
|
if fOptions.Listen != "" {
|
|
Listen(fOptions.Listen)
|
|
}
|
|
scheduleLoop()
|
|
}
|
|
|
|
// Start command execution
|
|
cmd.HandleFunc(ctx, args)
|
|
}
|
|
|
|
func configureHandle(ctx context.Context, args []string) {
|
|
// Ensure config file has been written
|
|
_, err := ReadConfig(fOptions.ConfigPath)
|
|
if os.IsNotExist(err) {
|
|
err = WriteConfig(cfg.DefaultConfig, fOptions.ConfigPath)
|
|
if err != nil {
|
|
log.Fatalf("Unable to write default config: %s", err)
|
|
}
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, EditorName(), fOptions.ConfigPath)
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
log.Fatalf("Unable to start editor: %s", err)
|
|
}
|
|
}
|
|
|
|
func InteractiveHandle(ctx context.Context, args []string) {
|
|
var http string
|
|
set := pflag.NewFlagSet("interactive", pflag.ExitOnError)
|
|
set.StringVar(&http, "http", "", "HTTP listen address")
|
|
set.Lookup("http").NoOptDefVal = config.HTTPAddr
|
|
set.Parse(args)
|
|
|
|
if http == "" {
|
|
Interactive(ctx)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
go func() {
|
|
if err := ListenAndServe(ctx, http); err != nil {
|
|
log.Println(err)
|
|
}
|
|
}()
|
|
time.Sleep(time.Second)
|
|
Interactive(ctx)
|
|
}
|
|
|
|
func httpHandle(ctx context.Context, args []string) {
|
|
addr := config.HTTPAddr
|
|
if addr == "" {
|
|
addr = ":8080" // For backwards compatibility (remove in future)
|
|
}
|
|
|
|
set := pflag.NewFlagSet("http", pflag.ExitOnError)
|
|
set.StringVarP(&addr, "addr", "a", addr, "Listen address.")
|
|
set.Parse(args)
|
|
|
|
if addr == "" {
|
|
set.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
promptHub.OmitTerminal(true)
|
|
|
|
if err := ListenAndServe(ctx, addr); err != nil {
|
|
log.Println(err)
|
|
}
|
|
}
|
|
|
|
func connectHandle(_ context.Context, args []string) {
|
|
if args[0] == "" {
|
|
fmt.Println("Missing argument, try 'connect help'.")
|
|
}
|
|
if success := Connect(args[0]); !success {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func helpHandle(args []string) {
|
|
arg := args[0]
|
|
|
|
var cmd *Command
|
|
for _, c := range commands {
|
|
if c.Str == arg {
|
|
cmd = &c
|
|
break
|
|
}
|
|
}
|
|
if arg == "" || cmd == nil {
|
|
pflag.Usage()
|
|
return
|
|
}
|
|
cmd.PrintUsage()
|
|
}
|
|
|
|
func cleanup() {
|
|
debug.Printf("Starting cleanup")
|
|
defer debug.Printf("Cleanup done")
|
|
|
|
abortActiveConnection(false)
|
|
listenHub.Close()
|
|
if adTNC != nil {
|
|
if err := adTNC.Close(); err != nil {
|
|
log.Printf("Failure to close ardop TNC: %s", err)
|
|
}
|
|
}
|
|
if pModem != nil {
|
|
if err := pModem.Close(); err != nil {
|
|
log.Printf("Failure to close pactor modem: %s", err)
|
|
}
|
|
}
|
|
|
|
if varaFMModem != nil {
|
|
if err := varaFMModem.Close(); err != nil {
|
|
log.Printf("Failure to close varafm modem: %s", err)
|
|
}
|
|
}
|
|
|
|
if varaHFModem != nil {
|
|
if err := varaHFModem.Close(); err != nil {
|
|
log.Printf("Failure to close varahf modem: %s", err)
|
|
}
|
|
}
|
|
|
|
if agwpeTNC != nil {
|
|
if err := agwpeTNC.Close(); err != nil {
|
|
log.Printf("Failure to close AGWPE TNC: %s", err)
|
|
}
|
|
}
|
|
eventLog.Close()
|
|
}
|
|
|
|
func loadMBox() {
|
|
mbox = mailbox.NewDirHandler(
|
|
filepath.Join(fOptions.MailboxPath, fOptions.MyCall),
|
|
fOptions.SendOnly,
|
|
)
|
|
|
|
// Ensure the mailbox handler is ready
|
|
if err := mbox.Prepare(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func loadHamlibRigs() map[string]hamlib.VFO {
|
|
rigs := make(map[string]hamlib.VFO, len(config.HamlibRigs))
|
|
|
|
for name, conf := range config.HamlibRigs {
|
|
if conf.Address == "" {
|
|
log.Printf("Missing address-field for rig '%s', skipping.", name)
|
|
continue
|
|
}
|
|
|
|
rig, err := hamlib.Open(conf.Network, conf.Address)
|
|
if err != nil {
|
|
log.Printf("Initialization hamlib rig %s failed: %s.", name, err)
|
|
continue
|
|
}
|
|
|
|
var vfo hamlib.VFO
|
|
switch strings.ToUpper(conf.VFO) {
|
|
case "A", "VFOA":
|
|
vfo, err = rig.VFOA()
|
|
case "B", "VFOB":
|
|
vfo, err = rig.VFOB()
|
|
case "":
|
|
vfo = rig.CurrentVFO()
|
|
default:
|
|
log.Printf("Cannot load rig '%s': Unrecognized VFO identifier '%s'", name, conf.VFO)
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("Cannot load rig '%s': Unable to select VFO: %s", name, err)
|
|
continue
|
|
}
|
|
|
|
f, err := vfo.GetFreq()
|
|
if err != nil {
|
|
log.Printf("Unable to get frequency from rig %s: %s.", name, err)
|
|
} else {
|
|
log.Printf("%s ready. Dial frequency is %s.", name, Frequency(f))
|
|
}
|
|
|
|
rigs[name] = vfo
|
|
}
|
|
return rigs
|
|
}
|
|
|
|
func extractMessageHandle(_ context.Context, args []string) {
|
|
if len(args) == 0 || args[0] == "" {
|
|
panic("TODO: usage")
|
|
}
|
|
|
|
file, _ := os.Open(args[0])
|
|
defer file.Close()
|
|
|
|
msg := new(fbb.Message)
|
|
if err := msg.ReadFrom(file); err != nil {
|
|
log.Fatal(err)
|
|
} else {
|
|
fmt.Println(msg)
|
|
for _, f := range msg.Files() {
|
|
if err := os.WriteFile(f.Name(), f.Data(), 0o664); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func EditorName() string {
|
|
if e := os.Getenv("EDITOR"); e != "" {
|
|
return e
|
|
} else if e := os.Getenv("VISUAL"); e != "" {
|
|
return e
|
|
}
|
|
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
return "notepad"
|
|
case "linux":
|
|
if path, err := exec.LookPath("editor"); err == nil {
|
|
return path
|
|
}
|
|
}
|
|
|
|
return "vi"
|
|
}
|
|
|
|
func posReportHandle(ctx context.Context, args []string) {
|
|
var latlon, comment string
|
|
|
|
set := pflag.NewFlagSet("position", pflag.ExitOnError)
|
|
set.StringVar(&latlon, "latlon", "", "")
|
|
set.StringVarP(&comment, "comment", "c", "", "")
|
|
set.Parse(args)
|
|
|
|
report := catalog.PosReport{Comment: comment}
|
|
|
|
if latlon != "" {
|
|
parts := strings.Split(latlon, ",")
|
|
if len(parts) != 2 {
|
|
log.Fatal(`Invalid position format. Expected "latitude,longitude".`)
|
|
}
|
|
|
|
lat, err := strconv.ParseFloat(parts[0], 64)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
report.Lat = &lat
|
|
|
|
lon, err := strconv.ParseFloat(parts[1], 64)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
report.Lon = &lon
|
|
} else if config.GPSd.Addr != "" {
|
|
conn, err := gpsd.Dial(config.GPSd.Addr)
|
|
if err != nil {
|
|
log.Fatalf("GPSd daemon: %s", err)
|
|
}
|
|
defer conn.Close()
|
|
conn.Watch(true)
|
|
|
|
posChan := make(chan gpsd.Position)
|
|
go func() {
|
|
defer close(posChan)
|
|
pos, err := conn.NextPos()
|
|
if err != nil {
|
|
log.Printf("GPSd: %s", err)
|
|
return
|
|
}
|
|
posChan <- pos
|
|
}()
|
|
|
|
log.Println("Waiting for position from GPSd...") // TODO: Spinning bar?
|
|
var pos gpsd.Position
|
|
select {
|
|
case p := <-posChan:
|
|
pos = p
|
|
case <-ctx.Done():
|
|
log.Println("Cancelled")
|
|
return
|
|
}
|
|
report.Lat = &pos.Lat
|
|
report.Lon = &pos.Lon
|
|
if config.GPSd.UseServerTime {
|
|
report.Date = time.Now()
|
|
} else {
|
|
report.Date = pos.Time
|
|
}
|
|
|
|
// Course and speed is part of the spec, but does not seem to be
|
|
// supported by winlink.org anymore. Ignore it for now.
|
|
if false && pos.Track != 0 {
|
|
course := CourseFromFloat64(pos.Track, false)
|
|
report.Course = &course
|
|
}
|
|
} else {
|
|
fmt.Println("No position available. See --help")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if report.Date.IsZero() {
|
|
report.Date = time.Now()
|
|
}
|
|
|
|
postMessage(report.Message(fOptions.MyCall))
|
|
}
|
|
|
|
func CourseFromFloat64(f float64, magnetic bool) catalog.Course {
|
|
c := catalog.Course{Magnetic: magnetic}
|
|
|
|
str := fmt.Sprintf("%03.0f", f)
|
|
for i := 0; i < 3; i++ {
|
|
c.Digits[i] = str[i]
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
func postMessage(msg *fbb.Message) {
|
|
if err := msg.Validate(); err != nil {
|
|
fmt.Printf("WARNING - Message does not validate: %s\n", err)
|
|
}
|
|
if err := mbox.AddOut(msg); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Println("Message posted")
|
|
}
|