Switch the lab reflector to jayReflector and harden local station use.

Build DJ1JAY’s fork with an optional Satellite/Trunk stub, allow -c/--config, open capture and playback as separate devices, and add a lab start helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 12:14:41 +02:00
co-authored by Cursor
parent f90b6150f3
commit f8cad55e9c
24 changed files with 847 additions and 225 deletions
+5 -5
View File
@@ -31,9 +31,9 @@ func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []strin
func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []string, logPath string) loggedIn {
t.Helper()
refl := newReflector(t)
file := configJSON("127.0.0.1", refl.port, callsign, key, tg)
file := configJSON(t, "127.0.0.1", refl.port, callsign, key, tg)
if logPath != "" {
file = configWithLog("127.0.0.1", refl.port, callsign, key, tg, logPath)
file = configWithLog(t, "127.0.0.1", refl.port, callsign, key, tg, logPath)
}
path := writeConfigFile(t, file)
clock := newFakeClock()
@@ -151,7 +151,7 @@ func TestRefusedConnectionRetriesUntilQuit(t *testing.T) {
port := ln.Addr().(*net.TCPAddr).Port
ln.Close()
path := writeConfigFile(t, configJSON("127.0.0.1", port, "DL1ABC", "key", 4))
path := writeConfigFile(t, configJSON(t, "127.0.0.1", port, "DL1ABC", "key", 4))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -196,7 +196,7 @@ func TestReflectorErrorStopsRetries(t *testing.T) {
for _, msg := range []string{"Access denied", "no such station"} {
t.Run(msg, func(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON("127.0.0.1", refl.port, "DL1ABC", "key", 20))
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -230,7 +230,7 @@ func TestReflectorErrorStopsRetries(t *testing.T) {
func TestMissingOpusStopsRetries(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON("127.0.0.1", refl.port, "DL1ABC", "key", 20))
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
+6 -4
View File
@@ -1,7 +1,9 @@
package station
import (
"errors"
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
@@ -48,13 +50,13 @@ var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z
func loadConfig(path string) (loaded, error) {
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
if errors.Is(err, fs.ErrNotExist) {
if err := writeTemplate(path); err != nil {
return loaded{}, err
}
return loaded{}, fmt.Errorf("created %s", path)
}
return loaded{}, fmt.Errorf("could not be read")
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
}
return parseConfig(path, body)
}
@@ -83,7 +85,7 @@ func writeTemplate(path string) error {
func parseConfig(path string, body []byte) (loaded, error) {
var shape fileShape
if err := jsonv2.Unmarshal(body, &shape); err != nil {
return loaded{}, fmt.Errorf("could not be read")
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
}
if block, notice := blockedLog(shape); block {
cfg, err := parseOperational(shape)
@@ -99,7 +101,7 @@ func parseConfig(path string, body []byte) (loaded, error) {
}
shape = withLogDefaults(shape)
if err := rewriteConfig(path, shape); err != nil {
return loaded{}, fmt.Errorf("could not be read")
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
}
}
cfg, err := parseOperational(shape)
+33 -22
View File
@@ -84,32 +84,41 @@ func TestExistingConfigIsNotRewritten(t *testing.T) {
func TestInvalidConfigNamesTheField(t *testing.T) {
tests := []struct {
name string
body string
want string
name string
host string
port int
callsign string
authKey string
talkGroup int
body string
want string
}{
{name: "empty host", body: configJSON("", 5308, "DL1ABC", "key", 1), want: "host is empty"},
{name: "blank host", body: configJSON(" ", 5308, "DL1ABC", "key", 1), want: "host is empty"},
{name: "empty host", host: "", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"},
{name: "blank host", host: " ", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"},
{name: "host type", body: `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "host has the wrong type"},
{name: "empty callsign", body: configJSON("127.0.0.1", 5308, "", "key", 1), want: "callsign is empty"},
{name: "empty callsign", host: "127.0.0.1", port: 5308, callsign: "", authKey: "key", talkGroup: 1, want: "callsign is empty"},
{name: "callsign type", body: `{"host":"127.0.0.1","port":5308,"callsign":1,"authKey":"key","talkGroup":1}`, want: "callsign has the wrong type"},
{name: "empty auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", "", 1), want: "authKey is empty"},
{name: "whitespace auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", " \t", 1), want: "authKey is empty"},
{name: "empty auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "", talkGroup: 1, want: "authKey is empty"},
{name: "whitespace auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: " \t", talkGroup: 1, want: "authKey is empty"},
{name: "auth key type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":1,"talkGroup":1}`, want: "authKey has the wrong type"},
{name: "port zero", body: configJSON("127.0.0.1", 0, "DL1ABC", "key", 1), want: "port is invalid"},
{name: "port negative", body: configJSON("127.0.0.1", -1, "DL1ABC", "key", 1), want: "port is invalid"},
{name: "port too high", body: configJSON("127.0.0.1", 65536, "DL1ABC", "key", 1), want: "port is invalid"},
{name: "port zero", host: "127.0.0.1", port: 0, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
{name: "port negative", host: "127.0.0.1", port: -1, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
{name: "port too high", host: "127.0.0.1", port: 65536, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
{name: "port type", body: `{"host":"127.0.0.1","port":"5308","callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port has the wrong type"},
{name: "port fraction", body: `{"host":"127.0.0.1","port":5308.5,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port is invalid"},
{name: "talk group zero", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", 0), want: "talkGroup is invalid"},
{name: "talk group negative", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", -3), want: "talkGroup is invalid"},
{name: "talk group zero", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 0, want: "talkGroup is invalid"},
{name: "talk group negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, want: "talkGroup is invalid"},
{name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"},
{name: "bad callsign", body: configJSON("127.0.0.1", 5308, "HELLO", "key", 1), want: "callsign is invalid"},
{name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := tt.body
if body == "" {
body = configJSON(t, tt.host, tt.port, tt.callsign, tt.authKey, tt.talkGroup)
}
path := filepath.Join(t.TempDir(), "funknetz.json")
if err := os.WriteFile(path, []byte(tt.body), 0o600); err != nil {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(path)
@@ -156,8 +165,8 @@ func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) {
if err == nil {
t.Fatal("New() error = nil, want a read failure")
}
if err.Error() != "could not be read" {
t.Fatalf("New() error = %q, want %q", err.Error(), "could not be read")
if !strings.Contains(err.Error(), "the file could not be read") {
t.Fatalf("New() error = %q, want it to say the file could not be read", err.Error())
}
if strings.Contains(err.Error(), "callsign") {
t.Fatalf("New() error = %q, want no field name", err.Error())
@@ -166,7 +175,7 @@ func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) {
func TestCallsignIsTrimmedAndUppercased(t *testing.T) {
path := filepath.Join(t.TempDir(), "funknetz.json")
body := configJSON(" 127.0.0.1 ", 5308, " dl1abc-r ", "a b", 20)
body := configJSON(t, " 127.0.0.1 ", 5308, " dl1abc-r ", "a b", 20)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
@@ -359,7 +368,8 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
})
}
func configJSON(host string, port int, callsign, authKey string, talkGroup int) string {
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
t.Helper()
body, err := jsonv2.Marshal(map[string]any{
"host": host,
"port": port,
@@ -368,12 +378,13 @@ func configJSON(host string, port int, callsign, authKey string, talkGroup int)
"talkGroup": talkGroup,
})
if err != nil {
panic(err)
t.Fatal(err)
}
return string(body)
}
func configWithLog(host string, port int, callsign, authKey string, talkGroup int, logPath string) string {
func configWithLog(t testing.TB, host string, port int, callsign, authKey string, talkGroup int, logPath string) string {
t.Helper()
body, err := jsonv2.Marshal(map[string]any{
"host": host,
"port": port,
@@ -384,7 +395,7 @@ func configWithLog(host string, port int, callsign, authKey string, talkGroup in
"logFile": logPath,
})
if err != nil {
panic(err)
t.Fatal(err)
}
return string(body)
}
+1 -1
View File
@@ -31,7 +31,7 @@ func TestConnectsToLabReflector(t *testing.T) {
}
conn.Close()
path := writeConfigFile(t, configJSON(labHost, labPort, labCallsign, labAuthKey, labTalkGroup))
path := writeConfigFile(t, configJSON(t, labHost, labPort, labCallsign, labAuthKey, labTalkGroup))
st, err := station.New(station.Options{ConfigPath: path})
if err != nil {
t.Fatal(err)
+2 -2
View File
@@ -123,7 +123,7 @@ func TestLogFailureStaysInTheIdleSlot(t *testing.T) {
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))
path := writeConfigFile(t, configWithLog(t, "127.0.0.1", ln, "DL1ABC", "key", 4, logPath))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -152,7 +152,7 @@ func TestFirstDialFailureIsLoggedOnce(t *testing.T) {
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))
path := writeConfigFile(t, configWithLog(t, "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 {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON(" 127.0.0.1 ", refl.port, " dl1abc ", "auth key with spaces", 20))
path := writeConfigFile(t, configJSON(t, " 127.0.0.1 ", refl.port, " dl1abc ", "auth key with spaces", 20))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
+2 -2
View File
@@ -1,6 +1,6 @@
package station
import "encoding/json"
import jsonv2 "encoding/json/v2"
// SoftwareName and SoftwareVersion are the identity sent to the reflector
// after login. Edit them here.
@@ -10,7 +10,7 @@ const (
)
func softwareIdentityBody() ([]byte, error) {
raw, err := json.Marshal(struct {
raw, err := jsonv2.Marshal(struct {
SW string `json:"sw"`
SWVer string `json:"swVer"`
}{