Append an optional station log and announce the software after login.
A log path that cannot be written leaves the station on the reflector, and the reflector learns which program joined. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Optional station log
|
||||||
|
|
||||||
|
The operator can append a log of the session. `~/.funknetz.json` gains `enableLogging` and `logFile` when either key is missing, so the switch is visible in the file they already edit. A missing key is not the same as a bad `port`: a log path that is mistyped or not writable leaves the station running and shows the failure in the window. Quitting would drop the callsign off the reflector because the log could not be written. The file is append-only so the operator can follow it with `tail -f`. Silent defaults were rejected because the new keys would stay invisible.
|
||||||
@@ -24,9 +24,18 @@ type loggedIn struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []string) loggedIn {
|
func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []string) loggedIn {
|
||||||
|
t.Helper()
|
||||||
|
return loginLog(t, callsign, key, tg, id, codecs, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []string, logPath string) loggedIn {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON("127.0.0.1", refl.port, callsign, key, tg))
|
file := configJSON("127.0.0.1", refl.port, callsign, key, tg)
|
||||||
|
if logPath != "" {
|
||||||
|
file = configWithLog("127.0.0.1", refl.port, callsign, key, tg, logPath)
|
||||||
|
}
|
||||||
|
path := writeConfigFile(t, file)
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,6 +72,11 @@ func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []strin
|
|||||||
refl.writeFrame(t, 12, nil)
|
refl.writeFrame(t, 12, nil)
|
||||||
refl.writeFrame(t, 100, serverInfo(id, []string{"db0abc"}, codecs))
|
refl.writeFrame(t, 100, serverInfo(id, []string{"db0abc"}, codecs))
|
||||||
typ, body = refl.readFrame(t)
|
typ, body = refl.readFrame(t)
|
||||||
|
if typ != 111 {
|
||||||
|
t.Fatalf("type %d, want software identity", typ)
|
||||||
|
}
|
||||||
|
requireSoftwareIdentity(t, body)
|
||||||
|
typ, body = refl.readFrame(t)
|
||||||
if typ != 106 {
|
if typ != 106 {
|
||||||
t.Fatalf("type %d, want select talk group", typ)
|
t.Fatalf("type %d, want select talk group", typ)
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,6 +18,9 @@ type loaded struct {
|
|||||||
callsign string
|
callsign string
|
||||||
authKey string
|
authKey string
|
||||||
talkGroup int
|
talkGroup int
|
||||||
|
enableLogging bool
|
||||||
|
logPath string
|
||||||
|
logNotice string
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileShape struct {
|
type fileShape struct {
|
||||||
@@ -25,6 +29,8 @@ type fileShape struct {
|
|||||||
Callsign jsontext.Value `json:"callsign"`
|
Callsign jsontext.Value `json:"callsign"`
|
||||||
AuthKey jsontext.Value `json:"authKey"`
|
AuthKey jsontext.Value `json:"authKey"`
|
||||||
TalkGroup jsontext.Value `json:"talkGroup"`
|
TalkGroup jsontext.Value `json:"talkGroup"`
|
||||||
|
EnableLogging jsontext.Value `json:"enableLogging"`
|
||||||
|
LogFile jsontext.Value `json:"logFile"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type templateFile struct {
|
type templateFile struct {
|
||||||
@@ -33,6 +39,8 @@ type templateFile struct {
|
|||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
AuthKey string `json:"authKey"`
|
AuthKey string `json:"authKey"`
|
||||||
TalkGroup int `json:"talkGroup"`
|
TalkGroup int `json:"talkGroup"`
|
||||||
|
EnableLogging bool `json:"enableLogging"`
|
||||||
|
LogFile string `json:"logFile"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?$`)
|
var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?$`)
|
||||||
@@ -48,11 +56,11 @@ func loadConfig(path string) (loaded, error) {
|
|||||||
}
|
}
|
||||||
return loaded{}, fmt.Errorf("could not be read")
|
return loaded{}, fmt.Errorf("could not be read")
|
||||||
}
|
}
|
||||||
return parseConfig(body)
|
return parseConfig(path, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeTemplate(path string) error {
|
func writeTemplate(path string) error {
|
||||||
body, err := jsonv2.Marshal(templateFile{})
|
body, err := jsonv2.Marshal(templateFile{LogFile: defaultLogPath()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -72,11 +80,36 @@ func writeTemplate(path string) error {
|
|||||||
return os.Chmod(path, 0o600)
|
return os.Chmod(path, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseConfig(body []byte) (loaded, error) {
|
func parseConfig(path string, body []byte) (loaded, error) {
|
||||||
var shape fileShape
|
var shape fileShape
|
||||||
if err := jsonv2.Unmarshal(body, &shape); err != nil {
|
if err := jsonv2.Unmarshal(body, &shape); err != nil {
|
||||||
return loaded{}, fmt.Errorf("could not be read")
|
return loaded{}, fmt.Errorf("could not be read")
|
||||||
}
|
}
|
||||||
|
if block, notice := blockedLog(shape); block {
|
||||||
|
cfg, err := parseOperational(shape)
|
||||||
|
if err != nil {
|
||||||
|
return loaded{}, err
|
||||||
|
}
|
||||||
|
cfg.logNotice = notice
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if missingLog(shape) {
|
||||||
|
if !operationalKindsOK(shape) {
|
||||||
|
return parseOperational(shape)
|
||||||
|
}
|
||||||
|
shape = withLogDefaults(shape)
|
||||||
|
if err := rewriteConfig(path, shape); err != nil {
|
||||||
|
return loaded{}, fmt.Errorf("could not be read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg, err := parseOperational(shape)
|
||||||
|
if err != nil {
|
||||||
|
return loaded{}, err
|
||||||
|
}
|
||||||
|
return applyLog(cfg, shape), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOperational(shape fileShape) (loaded, error) {
|
||||||
host, err := stringField("host", shape.Host)
|
host, err := stringField("host", shape.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return loaded{}, err
|
return loaded{}, err
|
||||||
@@ -153,3 +186,139 @@ func intField(name string, v jsontext.Value, min, max int) (int, error) {
|
|||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultLogPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "funknetz.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
func present(v jsontext.Value) bool { return len(v) > 0 }
|
||||||
|
|
||||||
|
func isBool(v jsontext.Value) bool {
|
||||||
|
return v.Kind() == jsontext.KindTrue || v.Kind() == jsontext.KindFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
func missingLog(shape fileShape) bool {
|
||||||
|
return !present(shape.EnableLogging) || !present(shape.LogFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationalKindsOK(shape fileShape) bool {
|
||||||
|
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if present(shape.Callsign) && shape.Callsign.Kind() != jsontext.KindString {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if present(shape.AuthKey) && shape.AuthKey.Kind() != jsontext.KindString {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if present(shape.Port) && shape.Port.Kind() != jsontext.KindNumber {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockedLog reports that a log value has the wrong JSON type, so the file
|
||||||
|
// must not be rewritten. notice is empty when logging is not switched on.
|
||||||
|
func blockedLog(shape fileShape) (block bool, notice string) {
|
||||||
|
enBad := present(shape.EnableLogging) && !isBool(shape.EnableLogging)
|
||||||
|
fileBad := present(shape.LogFile) && shape.LogFile.Kind() != jsontext.KindString
|
||||||
|
if !enBad && !fileBad {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if enBad {
|
||||||
|
return true, "enableLogging has the wrong type"
|
||||||
|
}
|
||||||
|
if shape.EnableLogging.Kind() == jsontext.KindTrue {
|
||||||
|
return true, "logFile has the wrong type"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func withLogDefaults(shape fileShape) fileShape {
|
||||||
|
shape.Host = rawOr(shape.Host, `""`)
|
||||||
|
shape.Port = rawOr(shape.Port, "0")
|
||||||
|
shape.Callsign = rawOr(shape.Callsign, `""`)
|
||||||
|
shape.AuthKey = rawOr(shape.AuthKey, `""`)
|
||||||
|
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
||||||
|
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
||||||
|
if !present(shape.LogFile) {
|
||||||
|
raw, err := jsonv2.Marshal(defaultLogPath())
|
||||||
|
if err != nil {
|
||||||
|
raw = []byte(`""`)
|
||||||
|
}
|
||||||
|
shape.LogFile = jsontext.Value(raw)
|
||||||
|
}
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawOr(v jsontext.Value, fallback string) jsontext.Value {
|
||||||
|
if len(v) == 0 {
|
||||||
|
return jsontext.Value(fallback)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteConfig(path string, shape fileShape) error {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := jsonv2.Marshal(shape)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = append(body, '\n')
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".funknetz-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
_, werr := tmp.Write(body)
|
||||||
|
cerr := tmp.Chmod(info.Mode().Perm())
|
||||||
|
clerr := tmp.Close()
|
||||||
|
if werr != nil || cerr != nil || clerr != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
if werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
if cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
return clerr
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyLog(cfg loaded, shape fileShape) loaded {
|
||||||
|
if shape.EnableLogging.Kind() != jsontext.KindTrue {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
var path string
|
||||||
|
if err := jsonv2.Unmarshal(shape.LogFile, &path); err != nil {
|
||||||
|
cfg.logNotice = "logFile has the wrong type"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
cfg.logNotice = "Cannot write logFile"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
cfg.logNotice = "Cannot write " + path
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
cfg.enableLogging = true
|
||||||
|
cfg.logPath = path
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|||||||
+213
-1
@@ -44,11 +44,21 @@ func TestMissingConfigIsCreatedAndRejected(t *testing.T) {
|
|||||||
if got["port"] != float64(0) || got["talkGroup"] != float64(0) {
|
if got["port"] != float64(0) || got["talkGroup"] != float64(0) {
|
||||||
t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got)
|
t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got)
|
||||||
}
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["enableLogging"] != false {
|
||||||
|
t.Fatalf("enableLogging = %#v, want false", got["enableLogging"])
|
||||||
|
}
|
||||||
|
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
||||||
|
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExistingConfigIsNotRewritten(t *testing.T) {
|
func TestExistingConfigIsNotRewritten(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20}\n")
|
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||||
if err := os.WriteFile(path, original, 0o640); err != nil {
|
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -117,9 +127,22 @@ func TestInvalidConfigNamesTheField(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(tt.want, "wrong type") {
|
||||||
if string(after) != string(before) {
|
if string(after) != string(before) {
|
||||||
t.Fatalf("file = %q, want it left untouched", after)
|
t.Fatalf("file = %q, want it left untouched", after)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := jsonv2.Unmarshal(after, &got); err != nil {
|
||||||
|
t.Fatalf("rewritten file is not JSON: %v\n%s", err, after)
|
||||||
|
}
|
||||||
|
if got["enableLogging"] != false {
|
||||||
|
t.Fatalf("enableLogging = %#v, want false", got["enableLogging"])
|
||||||
|
}
|
||||||
|
if _, ok := got["logFile"].(string); !ok {
|
||||||
|
t.Fatalf("logFile = %#v, want a string", got["logFile"])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +186,179 @@ func TestCallsignIsTrimmedAndUppercased(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingLogKeysAreRewritten(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
|
original := []byte("{\"note\":true,\"host\":\" 127.0.0.1 \",\"port\":5308,\"callsign\":\" dl1abc \",\"authKey\":\"a b\",\"talkGroup\":20}\n")
|
||||||
|
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(st.Close)
|
||||||
|
if st.State().Callsign != "DL1ABC" {
|
||||||
|
t.Fatalf("Callsign = %q", st.State().Callsign)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := info.Mode().Perm(), os.FileMode(0o640); got != want {
|
||||||
|
t.Fatalf("mode = %o, want %o", got, want)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := jsonv2.Unmarshal(body, &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := got["note"]; ok {
|
||||||
|
t.Fatalf("note = %#v, want it dropped", got["note"])
|
||||||
|
}
|
||||||
|
if got["callsign"] != " dl1abc " || got["authKey"] != "a b" || got["host"] != " 127.0.0.1 " {
|
||||||
|
t.Fatalf("kept fields = %#v", got)
|
||||||
|
}
|
||||||
|
if got["enableLogging"] != false {
|
||||||
|
t.Fatalf("enableLogging = %#v", got["enableLogging"])
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
||||||
|
t.Fatalf("logFile = %#v", got["logFile"])
|
||||||
|
}
|
||||||
|
if st.State().Detail != "Idle" {
|
||||||
|
t.Fatalf("Detail = %q, want Idle", st.State().Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
t.Run("missing log file uses home", func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
|
body := []byte(`{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true}`)
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(st.Close)
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if err := jsonv2.Unmarshal(got, &fields); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantPath := filepath.Join(home, "funknetz.log")
|
||||||
|
if fields["logFile"] != wantPath || fields["enableLogging"] != true {
|
||||||
|
t.Fatalf("fields = %#v", fields)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wantPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("logging off ignores a bad path", func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
|
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
||||||
|
body := []byte(`{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":false,"logFile":1}`)
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(st.Close)
|
||||||
|
if st.State().Detail != "Idle" {
|
||||||
|
t.Fatalf("Detail = %q, want Idle", st.State().Detail)
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(after) != string(before) {
|
||||||
|
t.Fatalf("file = %s, want it left untouched", after)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("log file err = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
detail string
|
||||||
|
same bool
|
||||||
|
}{
|
||||||
|
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
|
||||||
|
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
|
||||||
|
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
|
||||||
|
{name: "enable type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":"/tmp/funknetz.log"}`, detail: "enableLogging has the wrong type", same: true},
|
||||||
|
{name: "file type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":1}`, detail: "logFile has the wrong type", same: true},
|
||||||
|
{name: "both types", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":1}`, detail: "enableLogging has the wrong type", same: true},
|
||||||
|
{name: "missing enable and bad file", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"logFile":1}`, detail: "Idle", same: true},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
|
if err := os.WriteFile(path, []byte(tt.body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(st.Close)
|
||||||
|
if st.State().Detail != tt.detail {
|
||||||
|
t.Fatalf("Detail = %q, want %q", st.State().Detail, tt.detail)
|
||||||
|
}
|
||||||
|
if tt.same {
|
||||||
|
after, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(after) != tt.body {
|
||||||
|
t.Fatalf("file = %s, want it left untouched", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("bad host still quits", func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
|
body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`)
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := station.New(station.Options{ConfigPath: path})
|
||||||
|
if err == nil || err.Error() != "host is empty" {
|
||||||
|
t.Fatalf("New() error = %v, want host is empty", err)
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(after) != string(body) {
|
||||||
|
t.Fatalf("file = %s, want it left untouched", after)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func configJSON(host string, port int, callsign, authKey string, talkGroup int) string {
|
func configJSON(host string, port int, callsign, authKey string, talkGroup int) string {
|
||||||
body, err := jsonv2.Marshal(map[string]any{
|
body, err := jsonv2.Marshal(map[string]any{
|
||||||
"host": host,
|
"host": host,
|
||||||
@@ -176,3 +372,19 @@ func configJSON(host string, port int, callsign, authKey string, talkGroup int)
|
|||||||
}
|
}
|
||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configWithLog(host string, port int, callsign, authKey string, talkGroup int, logPath string) string {
|
||||||
|
body, err := jsonv2.Marshal(map[string]any{
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"callsign": callsign,
|
||||||
|
"authKey": authKey,
|
||||||
|
"talkGroup": talkGroup,
|
||||||
|
"enableLogging": true,
|
||||||
|
"logFile": logPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package station
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Close releases the log file. It is safe to call more than once.
|
||||||
|
func (s *Station) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.closeLogLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) openLog() {
|
||||||
|
f, err := os.OpenFile(s.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
s.logNotice = "Cannot write " + s.logPath
|
||||||
|
if s.state.Talker == "" {
|
||||||
|
s.state.Detail = s.logNotice
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logFile = f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) closeLogLocked() {
|
||||||
|
if s.logFile != nil {
|
||||||
|
s.logFile.Close()
|
||||||
|
s.logFile = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) writeLog(ts time.Time, msg string) {
|
||||||
|
if s.logFile == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line := ts.Format(time.RFC3339) + " " + msg + "\n"
|
||||||
|
if _, err := s.logFile.Write([]byte(line)); err != nil {
|
||||||
|
s.closeLogLocked()
|
||||||
|
if s.logNotice == "" {
|
||||||
|
s.logNotice = "Cannot write " + s.logPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) noteTransmitLocked() {
|
||||||
|
s.noteTransmitAt(s.clock.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) noteTransmitAt(now time.Time) {
|
||||||
|
tx := s.transmitting()
|
||||||
|
if tx && !s.sending {
|
||||||
|
s.sending = true
|
||||||
|
s.sendStart = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tx && s.sending {
|
||||||
|
start := s.sendStart
|
||||||
|
s.sending = false
|
||||||
|
s.writeLog(start, fmt.Sprintf("sending for %d seconds.", wholeSeconds(start, now)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) closeTalkerLocked(now time.Time) {
|
||||||
|
if s.talkCall == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call := s.talkCall
|
||||||
|
start := s.talkStart
|
||||||
|
s.talkCall = ""
|
||||||
|
s.writeLog(start, fmt.Sprintf("%s talked for %d seconds.", call, wholeSeconds(start, now)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) noteDownLocked(now time.Time, dial bool) {
|
||||||
|
s.closeTalkerLocked(now)
|
||||||
|
s.state.Connection = "Disconnected"
|
||||||
|
s.remoteTalker = ""
|
||||||
|
s.roomBusy = false
|
||||||
|
s.noteTransmitAt(now)
|
||||||
|
switch {
|
||||||
|
case s.up:
|
||||||
|
s.up = false
|
||||||
|
s.outage = true
|
||||||
|
s.writeLog(now, "disconnected")
|
||||||
|
case dial && !s.outage:
|
||||||
|
s.outage = true
|
||||||
|
s.writeLog(now, "disconnected")
|
||||||
|
}
|
||||||
|
s.publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Station) endFatal(msg string, log bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.fatal != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := s.clock.Now()
|
||||||
|
s.closeTalkerLocked(now)
|
||||||
|
s.fatal = msg
|
||||||
|
s.remoteTalker = ""
|
||||||
|
s.roomBusy = false
|
||||||
|
s.noteTransmitAt(now)
|
||||||
|
if log {
|
||||||
|
s.up = false
|
||||||
|
s.outage = true
|
||||||
|
s.writeLog(now, oneLine(msg))
|
||||||
|
}
|
||||||
|
s.publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wholeSeconds(start, end time.Time) int {
|
||||||
|
d := end.Sub(start)
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(d / time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneLine(msg string) string {
|
||||||
|
return strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package station_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.neitzel.de/konrad/funknetz/station"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogAppendsSessionEvents(t *testing.T) {
|
||||||
|
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
||||||
|
if err := os.WriteFile(logPath, []byte("kept\n"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in := loginLog(t, "DL1ABC", "super-secret-key", 20, 7, []string{"OPUS"}, logPath)
|
||||||
|
|
||||||
|
in.clock.Advance(2 * time.Second)
|
||||||
|
in.refl.writeFrame(t, 104, appendStr(appendU32(nil, 20), "DB0XYZ"))
|
||||||
|
if _, err := in.st.Wait(context.Background(), func(s station.State) bool { return s.Talker == "DB0XYZ" }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.clock.Advance(3 * time.Second)
|
||||||
|
in.refl.tcp.Close()
|
||||||
|
if _, err := in.st.Wait(context.Background(), func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readLog(t, logPath)
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"kept",
|
||||||
|
"2026-10-03T12:00:00Z connected",
|
||||||
|
"2026-10-03T12:00:02Z DB0XYZ talked for 3 seconds.",
|
||||||
|
"2026-10-03T12:00:05Z disconnected",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("log = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "super-secret-key") {
|
||||||
|
t.Fatal("log contains the auth key")
|
||||||
|
}
|
||||||
|
info, err := os.Stat(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := info.Mode().Perm(), os.FileMode(0o640); got != want {
|
||||||
|
t.Fatalf("mode = %o, want %o", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogRecordsSendingAndARemoteTalker(t *testing.T) {
|
||||||
|
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
||||||
|
in := loginLog(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}, logPath)
|
||||||
|
|
||||||
|
in.st.HoldButton()
|
||||||
|
in.st.ReleaseButton()
|
||||||
|
in.clock.Advance(2 * time.Second)
|
||||||
|
in.st.HoldButton()
|
||||||
|
in.refl.writeFrame(t, 104, appendStr(appendU32(nil, 20), "DL1ABC"))
|
||||||
|
in.clock.Advance(2 * time.Second)
|
||||||
|
in.refl.writeFrame(t, 104, appendStr(appendU32(nil, 20), "DB0XYZ"))
|
||||||
|
if _, err := in.st.Wait(context.Background(), func(s station.State) bool { return s.Talker == "DB0XYZ" }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.st.HoldButton()
|
||||||
|
in.st.ReleaseButton()
|
||||||
|
in.clock.Advance(4 * time.Second)
|
||||||
|
in.refl.writeFrame(t, 105, appendStr(appendU32(nil, 20), "DB0XYZ"))
|
||||||
|
if _, err := in.st.Wait(context.Background(), func(s station.State) bool { return s.Talker == "" && !s.Transmitting }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readLog(t, logPath)
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"2026-10-03T12:00:00Z connected",
|
||||||
|
"2026-10-03T12:00:00Z sending for 0 seconds.",
|
||||||
|
"2026-10-03T12:00:02Z sending for 2 seconds.",
|
||||||
|
"2026-10-03T12:00:04Z DB0XYZ talked for 4 seconds.",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("log = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want {
|
||||||
|
t.Fatalf("mode = %o, want %o", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogFailureStaysInTheIdleSlot(t *testing.T) {
|
||||||
|
logPath := filepath.Join(t.TempDir(), "missing", "funknetz.log")
|
||||||
|
in := loginLog(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}, logPath)
|
||||||
|
notice := "Cannot write " + logPath
|
||||||
|
if got := in.st.State(); got.Connection != "Connected" || got.Detail != notice {
|
||||||
|
t.Fatalf("State = %+v, want Connected and the log failure", got)
|
||||||
|
}
|
||||||
|
in.refl.writeFrame(t, 104, appendStr(appendU32(nil, 20), "DB0XYZ"))
|
||||||
|
got, err := in.st.Wait(context.Background(), func(s station.State) bool { return s.Talker == "DB0XYZ" })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Detail != "Idle" {
|
||||||
|
t.Fatalf("Detail = %q, want the talker to take the line", got.Detail)
|
||||||
|
}
|
||||||
|
in.refl.writeFrame(t, 105, appendStr(appendU32(nil, 20), "DB0XYZ"))
|
||||||
|
got, err = in.st.Wait(context.Background(), func(s station.State) bool { return s.Talker == "" })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Detail != notice {
|
||||||
|
t.Fatalf("Detail = %q, want the log failure back", got.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstDialFailureIsLoggedOnce(t *testing.T) {
|
||||||
|
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
||||||
|
ln := listenClosed(t)
|
||||||
|
path := writeConfigFile(t, configWithLog("127.0.0.1", ln, "DL1ABC", "key", 4, logPath))
|
||||||
|
clock := newFakeClock()
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
go func() { _ = st.Run(ctx) }()
|
||||||
|
|
||||||
|
want := "2026-10-03T12:00:00Z disconnected\n"
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for readLog(t, logPath) != want {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("log = %q, want %q", readLog(t, logPath), want)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
clock.BlockUntilWaiting(t)
|
||||||
|
clock.Advance(2 * time.Second)
|
||||||
|
clock.BlockUntilWaiting(t)
|
||||||
|
if got := readLog(t, logPath); got != want {
|
||||||
|
t.Fatalf("log = %q, want the dial failure logged once", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectorErrorsAreOneLogLine(t *testing.T) {
|
||||||
|
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
||||||
|
refl := newReflector(t)
|
||||||
|
path := writeConfigFile(t, configWithLog("127.0.0.1", refl.port, "DL1ABC", "key", 20, logPath))
|
||||||
|
clock := newFakeClock()
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
go func() { _ = st.Run(ctx) }()
|
||||||
|
refl.accept(t)
|
||||||
|
refl.readFrame(t)
|
||||||
|
refl.writeFrame(t, 13, appendStr(nil, "bad\nline"))
|
||||||
|
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Detail == "bad\nline" }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := readLog(t, logPath)
|
||||||
|
if got != "2026-10-03T12:00:00Z bad line\n" {
|
||||||
|
t.Fatalf("log = %q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "disconnected") {
|
||||||
|
t.Fatalf("log = %q, want no disconnected line", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenClosed(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
ln.Close()
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLog(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
tcpTalkerStart = 104
|
tcpTalkerStart = 104
|
||||||
tcpTalkerStop = 105
|
tcpTalkerStop = 105
|
||||||
tcpSelectTG = 106
|
tcpSelectTG = 106
|
||||||
|
tcpNodeInfo = 111
|
||||||
|
|
||||||
udpHeartbeat = 1
|
udpHeartbeat = 1
|
||||||
udpAudio = 101
|
udpAudio = 101
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package station_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -283,6 +284,21 @@ func takeU32(t *testing.T, b []byte) (uint32, []byte) {
|
|||||||
return binary.BigEndian.Uint32(b[:4]), b[4:]
|
return binary.BigEndian.Uint32(b[:4]), b[4:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requireSoftwareIdentity(t *testing.T, body []byte) {
|
||||||
|
t.Helper()
|
||||||
|
raw, rest := takeStr(t, body)
|
||||||
|
if len(rest) != 0 {
|
||||||
|
t.Fatalf("software identity has %d trailing bytes", len(rest))
|
||||||
|
}
|
||||||
|
var got map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &got); err != nil {
|
||||||
|
t.Fatalf("software identity %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got["sw"] != station.SoftwareName || got["swVer"] != station.SoftwareVersion {
|
||||||
|
t.Fatalf("software identity = %#v, want sw %q and swVer %q only", got, station.SoftwareName, station.SoftwareVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func takeStr(t *testing.T, b []byte) (string, []byte) {
|
func takeStr(t *testing.T, b []byte) (string, []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
n, rest := takeU16(t, b)
|
n, rest := takeU16(t, b)
|
||||||
|
|||||||
+33
-15
@@ -20,6 +20,7 @@ var errStopped = errors.New("session stopped")
|
|||||||
|
|
||||||
// Run keeps the reflector session until ctx is canceled.
|
// Run keeps the reflector session until ctx is canceled.
|
||||||
func (s *Station) Run(ctx context.Context) error {
|
func (s *Station) Run(ctx context.Context) error {
|
||||||
|
defer s.Close()
|
||||||
for {
|
for {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -41,8 +42,7 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.state.Connection = "Disconnected"
|
s.noteDownLocked(s.clock.Now(), true)
|
||||||
s.publish()
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !s.wait(ctx, 2*time.Second) {
|
if !s.wait(ctx, 2*time.Second) {
|
||||||
return nil
|
return nil
|
||||||
@@ -54,14 +54,13 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.closeUDP()
|
s.closeUDP()
|
||||||
if s.fatal == "" {
|
|
||||||
s.state.Connection = "Disconnected"
|
|
||||||
s.remoteTalker = ""
|
|
||||||
s.roomBusy = false
|
|
||||||
}
|
|
||||||
s.mic = nil
|
s.mic = nil
|
||||||
s.play = nil
|
s.play = nil
|
||||||
|
if s.fatal == "" {
|
||||||
|
s.noteDownLocked(s.clock.Now(), false)
|
||||||
|
} else {
|
||||||
s.publish()
|
s.publish()
|
||||||
|
}
|
||||||
fatal := s.fatal != ""
|
fatal := s.fatal != ""
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -289,7 +288,7 @@ func (s *Station) onTCP(conn net.Conn, f frame, done <-chan struct{}, udpCh *cha
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
msg = "could not be read"
|
msg = "could not be read"
|
||||||
}
|
}
|
||||||
s.stop(msg)
|
s.endFatal(msg, true)
|
||||||
return errStopped
|
return errStopped
|
||||||
case tcpServerInfo:
|
case tcpServerInfo:
|
||||||
return s.onServerInfo(conn, f.body, done, udpCh, udpErr, udpReady, udpRx, lastTCP)
|
return s.onServerInfo(conn, f.body, done, udpCh, udpErr, udpReady, udpRx, lastTCP)
|
||||||
@@ -310,7 +309,7 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !hasOPUS(codecs) {
|
if !hasOPUS(codecs) {
|
||||||
s.stop(noOpus)
|
s.endFatal(noOpus, true)
|
||||||
return errStopped
|
return errStopped
|
||||||
}
|
}
|
||||||
enc, err := opus.NewEncoder()
|
enc, err := opus.NewEncoder()
|
||||||
@@ -322,6 +321,17 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
|
|||||||
enc.Close()
|
enc.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
info, err := softwareIdentityBody()
|
||||||
|
if err != nil {
|
||||||
|
enc.Close()
|
||||||
|
dec.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeFrame(conn, tcpNodeInfo, info); err != nil {
|
||||||
|
enc.Close()
|
||||||
|
dec.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
var tg [4]byte
|
var tg [4]byte
|
||||||
binary.BigEndian.PutUint32(tg[:], uint32(s.cfg.talkGroup))
|
binary.BigEndian.PutUint32(tg[:], uint32(s.cfg.talkGroup))
|
||||||
if err := writeFrame(conn, tcpSelectTG, tg[:]); err != nil {
|
if err := writeFrame(conn, tcpSelectTG, tg[:]); err != nil {
|
||||||
@@ -362,6 +372,10 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.state.Connection = "Connected"
|
s.state.Connection = "Connected"
|
||||||
|
s.up = true
|
||||||
|
s.outage = false
|
||||||
|
s.noteTransmitLocked()
|
||||||
|
s.writeLog(s.clock.Now(), "connected")
|
||||||
s.publish()
|
s.publish()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -372,12 +386,7 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) stop(msg string) {
|
func (s *Station) stop(msg string) { s.endFatal(msg, false) }
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.fatal = msg
|
|
||||||
s.publish()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail stops retries and shows message until the operator quits.
|
// Fail stops retries and shows message until the operator quits.
|
||||||
func (s *Station) Fail(message string) { s.stop(message) }
|
func (s *Station) Fail(message string) { s.stop(message) }
|
||||||
@@ -393,19 +402,24 @@ func (s *Station) onTalker(body []byte, start bool) {
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
now := s.clock.Now()
|
||||||
if start {
|
if start {
|
||||||
if call == s.cfg.callsign {
|
if call == s.cfg.callsign {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.closeTalkerLocked(now)
|
||||||
was := s.transmitting()
|
was := s.transmitting()
|
||||||
s.remoteTalker = call
|
s.remoteTalker = call
|
||||||
s.roomBusy = true
|
s.roomBusy = true
|
||||||
|
s.talkCall = call
|
||||||
|
s.talkStart = now
|
||||||
if was && !s.transmitting() {
|
if was && !s.transmitting() {
|
||||||
s.endTransmit()
|
s.endTransmit()
|
||||||
}
|
}
|
||||||
s.publish()
|
s.publish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.closeTalkerLocked(now)
|
||||||
s.remoteTalker = ""
|
s.remoteTalker = ""
|
||||||
s.roomBusy = false
|
s.roomBusy = false
|
||||||
s.publish()
|
s.publish()
|
||||||
@@ -500,6 +514,7 @@ func (s *Station) transmitting() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) publish() {
|
func (s *Station) publish() {
|
||||||
|
s.noteTransmitLocked()
|
||||||
s.state.Callsign = s.cfg.callsign
|
s.state.Callsign = s.cfg.callsign
|
||||||
s.state.TalkGroup = s.cfg.talkGroup
|
s.state.TalkGroup = s.cfg.talkGroup
|
||||||
if s.fatal != "" {
|
if s.fatal != "" {
|
||||||
@@ -517,6 +532,9 @@ func (s *Station) publish() {
|
|||||||
} else {
|
} else {
|
||||||
s.state.Talker = s.remoteTalker
|
s.state.Talker = s.remoteTalker
|
||||||
}
|
}
|
||||||
|
if s.state.Talker == "" && s.logNotice != "" {
|
||||||
|
s.state.Detail = s.logNotice
|
||||||
|
}
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case s.wake <- struct{}{}:
|
case s.wake <- struct{}{}:
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
|||||||
refl.writeFrame(t, 12, nil)
|
refl.writeFrame(t, 12, nil)
|
||||||
refl.writeFrame(t, 100, serverInfo(0x1234, []string{"db0abc"}, []string{"GSM", "OPUS"}))
|
refl.writeFrame(t, 100, serverInfo(0x1234, []string{"db0abc"}, []string{"GSM", "OPUS"}))
|
||||||
|
|
||||||
|
typ, body = refl.readFrame(t)
|
||||||
|
if typ != 111 {
|
||||||
|
t.Fatalf("frame type %d, want software identity", typ)
|
||||||
|
}
|
||||||
|
requireSoftwareIdentity(t, body)
|
||||||
|
|
||||||
typ, body = refl.readFrame(t)
|
typ, body = refl.readFrame(t)
|
||||||
if typ != 106 {
|
if typ != 106 {
|
||||||
t.Fatalf("frame type %d, want select talk group", typ)
|
t.Fatalf("frame type %d, want select talk group", typ)
|
||||||
@@ -89,7 +95,7 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
|||||||
t.Fatalf("State = %+v, want Connected Idle", got)
|
t.Fatalf("State = %+v, want Connected Idle", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No other talk group and no station profile: the next TCP message is a heartbeat, not type 107 or 111.
|
// No other talk group: the next TCP message is a heartbeat, not a monitor set.
|
||||||
clock.BlockUntilWaiting(t)
|
clock.BlockUntilWaiting(t)
|
||||||
clock.Advance(10 * time.Second)
|
clock.Advance(10 * time.Second)
|
||||||
typ, _ = refl.readFrame(t)
|
typ, _ = refl.readFrame(t)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package station
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// SoftwareName and SoftwareVersion are the identity sent to the reflector
|
||||||
|
// after login. Edit them here.
|
||||||
|
const (
|
||||||
|
SoftwareName = "funknetz"
|
||||||
|
SoftwareVersion = "1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
func softwareIdentityBody() ([]byte, error) {
|
||||||
|
raw, err := json.Marshal(struct {
|
||||||
|
SW string `json:"sw"`
|
||||||
|
SWVer string `json:"swVer"`
|
||||||
|
}{
|
||||||
|
SW: SoftwareName,
|
||||||
|
SWVer: SoftwareVersion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return appendString(nil, string(raw)), nil
|
||||||
|
}
|
||||||
+21
-1
@@ -5,6 +5,7 @@ package station
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,6 +49,16 @@ type Station struct {
|
|||||||
lastUDPSend time.Time
|
lastUDPSend time.Time
|
||||||
enc *opus.Encoder
|
enc *opus.Encoder
|
||||||
dec *opus.Decoder
|
dec *opus.Decoder
|
||||||
|
|
||||||
|
logFile *os.File
|
||||||
|
logPath string
|
||||||
|
logNotice string
|
||||||
|
up bool
|
||||||
|
outage bool
|
||||||
|
sending bool
|
||||||
|
sendStart time.Time
|
||||||
|
talkCall string
|
||||||
|
talkStart time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New loads the config at opt.ConfigPath. A missing file is created mode 0600
|
// New loads the config at opt.ConfigPath. A missing file is created mode 0600
|
||||||
@@ -61,17 +72,26 @@ func New(opt Options) (*Station, error) {
|
|||||||
if clock == nil {
|
if clock == nil {
|
||||||
clock = wallClock{}
|
clock = wallClock{}
|
||||||
}
|
}
|
||||||
|
detail := "Idle"
|
||||||
|
if cfg.logNotice != "" {
|
||||||
|
detail = cfg.logNotice
|
||||||
|
}
|
||||||
s := &Station{
|
s := &Station{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
wake: make(chan struct{}, 1),
|
wake: make(chan struct{}, 1),
|
||||||
|
logPath: cfg.logPath,
|
||||||
|
logNotice: cfg.logNotice,
|
||||||
state: State{
|
state: State{
|
||||||
Connection: "Disconnected",
|
Connection: "Disconnected",
|
||||||
Detail: "Idle",
|
Detail: detail,
|
||||||
Callsign: cfg.callsign,
|
Callsign: cfg.callsign,
|
||||||
TalkGroup: cfg.talkGroup,
|
TalkGroup: cfg.talkGroup,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if cfg.enableLogging {
|
||||||
|
s.openLog()
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user