feat(relays): generic HTTP relay board, and point band switching at it

An operator was about to build band→relay mapping into the custom URL rows.
It already exists, and better: relay auto-control has a per-relay "band" rule
driving webswitch, KMTronic, Dingtian, Denkovi and USB boards. It holds state
so a relay is only commanded when its wanted position changed, reads the
boards live before the first apply so it does not re-command one already in
place, and carries per-relay labels. A URL fired on a band change has none of
that, and would have been a second definition of "which antenna on which band"
— the drift that cost us a week on counties.

So the gap was only the hardware: a hand-made switch is none of the five named
types. It is now the sixth, "HTTP relay": an ON URL and an OFF URL, either as
one pattern with {relay} substituted, or one pair per relay. The per-relay
form is the reason it exists — these boxes often have URLs with nothing in
common between channels (…/FF0101 and …/FF0201), which no pattern can express.

Status is remembered rather than read: most have no endpoint worth trusting.
The cost is stated in the code and the panel — after a restart every relay is
re-commanded once, which is harmless on a board with no memory and far better
than assuming an antenna is already selected.

And the Connections panel now says so, exactly where the wrong choice is made:
picking a band change with a URL transport shows a note pointing at Station
Control. The URL transport stays — a lookup pushed to a webhook or a QSO to a
dashboard is an event, not a state, and Station Control has no place for it.
This commit is contained in:
2026-08-15 02:23:03 +02:00
parent ffd6bcf54b
commit 26b8dade20
8 changed files with 321 additions and 9 deletions
+16 -3
View File
@@ -14635,26 +14635,39 @@ func boolStr(b bool) string {
// StationDevice is one configured relay board for the Station Control tab.
type StationDevice struct {
ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic" | "dingtian" | "denkovi" | "usbrelay"
Type string `json:"type"` // "webswitch" | "kmtronic" | "dingtian" | "denkovi" | "usbrelay" | "httpgen"
Name string `json:"name"`
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
User string `json:"user,omitempty"` // KMTronic HTTP auth; Dingtian HTTP session ID (blank = none)
Pass string `json:"pass,omitempty"` // KMTronic HTTP auth; Dingtian relay password (09999)
Channels int `json:"channels,omitempty"` // usbrelay/denkovi/dingtian: number of relays
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
// Generic HTTP board only. Per-relay URLs win over the patterns, which is
// what lets a hand-made switch with nothing in common between its channels
// be described at all.
OnURLs []string `json:"on_urls,omitempty"` // index 0 = relay 1
OffURLs []string `json:"off_urls,omitempty"`
OnPat string `json:"on_pattern,omitempty"` // fallback, {relay} substituted
OffPat string `json:"off_pattern,omitempty"`
}
// deviceRelayCount is the relay count for a configured device — fixed by type,
// except the Denkovi (4 or 8) and the generic USB-serial board, whose channel
// count the user picks.
func deviceRelayCount(d StationDevice) int {
if d.Type == "usbrelay" || d.Type == "denkovi" || d.Type == "dingtian" {
if d.Type == "usbrelay" || d.Type == "denkovi" || d.Type == "dingtian" || d.Type == "httpgen" {
if d.Channels >= 1 {
return d.Channels
}
if d.Type == "dingtian" {
return 2 // the smallest board; the real count comes from its status reply
}
if d.Type == "httpgen" {
// A hand-made switch is usually two to four ways, and its URLs are
// typed one per relay — eight empty rows would be eight rows to read.
return 4
}
return 8
}
return relayCountFor(d.Type)
@@ -14788,7 +14801,7 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
}
for i := range devs {
switch devs[i].Type {
case "kmtronic", "dingtian", "denkovi", "usbrelay", "webswitch":
case "kmtronic", "dingtian", "denkovi", "usbrelay", "webswitch", "httpgen":
// known type — keep
default:
devs[i].Type = "webswitch"