Add org-level radio config

This commit is contained in:
Jonathon Leight committed 2026-10-25 13:40:23 -04:00
1 parent 6f82b437f3
commit ef0ca97e87
24 files changed
+1098 -194

No files matched your search

+19 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/MeshTender/MeshTender/internal/buildinfo"
@@ -107,8 +108,9 @@ type Config struct {
TrustedProxies []*net.IPNet
}
// RadioDefaults is a set of LoRa parameters, used to match a repeater's stored
// radio config against the region presets offered in the UI.
// RadioDefaults is a set of LoRa parameters: an organization's radio settings,
// or a repeater's stored radio config matched against the presets offered in
// the UI.
type RadioDefaults struct {
FreqHz uint32
BwHz uint32
@@ -116,6 +118,21 @@ type RadioDefaults struct {
CR uint8
}
// MHz is the frequency in the unit `set radio` takes, without trailing zeros.
func (r RadioDefaults) MHz() string { return strconv.FormatFloat(float64(r.FreqHz)/1e6, 'f', -1, 64) }
// KHz is the bandwidth in the unit `set radio` takes, without trailing zeros.
func (r RadioDefaults) KHz() string { return strconv.FormatFloat(float64(r.BwHz)/1e3, 'f', -1, 64) }
// Valid reports whether the firmware would accept these parameters: `set radio`
// rejects anything outside 150–2500 MHz, 7–500 kHz, SF 5–12 and CR 5–8
// (MeshCore src/helpers/CommonRadioPrefs.cpp).
func (r RadioDefaults) Valid() bool {
return r.FreqHz >= 150_000_000 && r.FreqHz <= 2_500_000_000 &&
r.BwHz >= 7_000 && r.BwHz <= 500_000 &&
r.SF >= 5 && r.SF <= 12 && r.CR >= 5 && r.CR <= 8
}
// Load reads configuration from the environment, applying defaults and
// validating required fields.
func Load() (*Config, error) {
+32 -20
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/MeshTender/MeshTender/internal/config"
"github.com/MeshTender/MeshTender/internal/store"
"github.com/MeshTender/MeshTender/internal/web"
)
@@ -170,7 +171,12 @@ func (s *Handlers) saveProfile(w http.ResponseWriter, r *http.Request, orgID, pi
if name == "" {
errs = append(errs, "Give the profile a name.")
}
_, steps := parseConfigSteps(stepsText, catalog, "profile", &errs, &risky)
radio, err := s.Store.OrgRadio(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load radio settings", err)
return
}
steps := parseConfigSteps(stepsText, catalog, radio, "profile", &errs, &risky)
if len(errs) == 0 {
if pid == 0 {
@@ -215,12 +221,15 @@ func (s *Handlers) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// parseConfigSteps turns textarea lines into config steps, validating each command
// against the catalog with the same resolver the console uses. Blank lines are
// skipped; "# ..." lines become comment steps; unknown commands are recorded in
// errs and risky commands in risky. It returns view steps (with rendered fields)
// and the store steps to persist.
func parseConfigSteps(text string, catalog []*store.Command, label string, errs, risky *[]string) (view, persist []store.ConfigStep) {
// parseConfigSteps turns textarea lines into config steps to persist, validating
// each command against the catalog with the same resolver the console uses.
// Blank lines are skipped; "# ..." lines become comment steps; unknown commands
// are recorded in errs and risky commands in risky. A line's {{ radio.* }}
// placeholders are stored canonical and validated filled in from radio — the
// org's radio settings — so a profile can't reference settings the org hasn't
// set.
func parseConfigSteps(text string, catalog []*store.Command, radio *config.RadioDefaults, label string, errs, risky *[]string) []store.ConfigStep {
var steps []store.ConfigStep
markerSeen := false
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
@@ -228,10 +237,7 @@ func parseConfigSteps(text string, catalog []*store.Command, label string, errs,
continue
}
if strings.HasPrefix(line, "#") {
comment := strings.TrimSpace(strings.TrimPrefix(line, "#"))
step := store.ConfigStep{Comment: comment}
view = append(view, step)
persist = append(persist, step)
steps = append(steps, store.ConfigStep{Comment: strings.TrimSpace(strings.TrimPrefix(line, "#"))})
continue
}
if isRegionMarkerLine(line) {
@@ -240,16 +246,24 @@ func parseConfigSteps(text string, catalog []*store.Command, label string, errs,
continue
}
markerSeen = true
step := store.ConfigStep{CommandLine: store.RegionMarker}
view = append(view, step)
persist = append(persist, step)
steps = append(steps, store.ConfigStep{CommandLine: store.RegionMarker})
continue
}
if !validCommandText(line) {
line, unknown := store.CanonicalizePlaceholders(line)
if len(unknown) > 0 {
*errs = append(*errs, fmt.Sprintf("%s: unknown placeholder %s in %q.", label, strings.Join(unknown, ", "), line))
continue
}
filled, ok := store.ExpandPlaceholders(line, radio)
if !ok {
*errs = append(*errs, fmt.Sprintf("%s: %q uses the organization's radio settings, which aren't set — set them under Radio first.", label, line))
continue
}
if !validCommandText(filled) { // what reaches the device
*errs = append(*errs, fmt.Sprintf("%s: invalid command %q.", label, line))
continue
}
cmd := resolveCommand(line, catalog)
cmd := resolveCommand(filled, catalog)
if cmd == nil {
*errs = append(*errs, fmt.Sprintf("%s: unknown command %q.", label, line))
continue
@@ -258,11 +272,9 @@ func parseConfigSteps(text string, catalog []*store.Command, label string, errs,
*risky = append(*risky, line)
}
id := cmd.ID
step := store.ConfigStep{CommandLine: line, CommandID: &id}
view = append(view, step)
persist = append(persist, step)
steps = append(steps, store.ConfigStep{CommandLine: line, CommandID: &id})
}
return view, persist
return steps
}
// isRegionMarkerLine reports whether a profile line is the region placeholder,
+40 -3
View File
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"github.com/MeshTender/MeshTender/internal/config"
"github.com/MeshTender/MeshTender/internal/store"
)
@@ -28,7 +29,7 @@ func TestParseConfigSteps(t *testing.T) {
}, "\n")
var errs, risky []string
_, persist := parseConfigSteps(text, catalog, "base", &errs, &risky)
persist := parseConfigSteps(text, catalog, nil, "base", &errs, &risky)
// One unknown command recorded.
if len(errs) != 1 || !strings.Contains(errs[0], "set nonsense 1") {
@@ -57,7 +58,7 @@ func TestParseConfigStepsRegionMarker(t *testing.T) {
// A marker (in flexible spelling) becomes one marker step that canonicalizes
// and round-trips; it is neither a comment nor a runnable command.
var errs, risky []string
_, persist := parseConfigSteps("set tx 22\n{{region}}\nset tx 23", catalog, "base", &errs, &risky)
persist := parseConfigSteps("set tx 22\n{{region}}\nset tx 23", catalog, nil, "base", &errs, &risky)
if len(errs) != 0 {
t.Fatalf("errs = %v, want none", errs)
}
@@ -73,9 +74,45 @@ func TestParseConfigStepsRegionMarker(t *testing.T) {
// A second marker is rejected.
errs, risky = nil, nil
_, _ = parseConfigSteps("{{ region }}\n{{ REGION }}", catalog, "base", &errs, &risky)
_ = parseConfigSteps("{{ region }}\n{{ REGION }}", catalog, nil, "base", &errs, &risky)
if len(errs) != 1 || !strings.Contains(errs[0], "once") {
t.Fatalf("errs = %v, want one 'only once' error", errs)
}
_ = risky
}
// TestParseConfigStepsRadioPlaceholders: a profile references the org's radio
// through placeholders, stored canonical and validated with the real values
// filled in — and refused while the org has no radio to fill them from.
func TestParseConfigStepsRadioPlaceholders(t *testing.T) {
t.Parallel()
catalog := configCatalog()
radio := &config.RadioDefaults{FreqHz: 910_525_000, BwHz: 62_500, SF: 7, CR: 5}
line := "set radio {{radio.freq}},{{ RADIO.BW }},{{ radio.sf }},{{radio.cr }}"
var errs, risky []string
persist := parseConfigSteps(line, catalog, radio, "base", &errs, &risky)
if len(errs) != 0 || len(persist) != 1 {
t.Fatalf("errs = %v, steps = %+v", errs, persist)
}
want := "set radio {{ radio.freq }},{{ radio.bw }},{{ radio.sf }},{{ radio.cr }}"
if persist[0].CommandLine != want || persist[0].CommandID == nil {
t.Fatalf("step = %+v, want canonical %q with a command id", persist[0], want)
}
// No org radio: nothing to fill in, so the line is rejected.
errs = nil
if got := parseConfigSteps(line, catalog, nil, "base", &errs, &risky); len(got) != 0 ||
len(errs) != 1 || !strings.Contains(errs[0], "aren't set") {
t.Fatalf("without a radio: steps %+v, errs %v", got, errs)
}
// A misspelled name, and the region marker used inline, are both unknown.
for _, bad := range []string{"set radio {{ radio.frequency }},62.5,7,5", "set tx {{ region }}"} {
errs = nil
if got := parseConfigSteps(bad, catalog, radio, "base", &errs, &risky); len(got) != 0 ||
len(errs) != 1 || !strings.Contains(errs[0], "unknown placeholder") {
t.Errorf("%q: steps %+v, errs %v", bad, got, errs)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package core
import (
"errors"
"net/http"
"github.com/MeshTender/MeshTender/internal/config"
"github.com/MeshTender/MeshTender/internal/store"
)
// pageRadioEdit renders the org radio settings editor, prefilled with the
// org's radio or, when it has none, the default preset.
func (s *Handlers) pageRadioEdit(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
radio, err := s.Store.OrgRadio(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load radio settings", err)
return
}
shown := defaultRadio()
if radio != nil {
shown = *radio
}
s.renderRadioEdit(w, r, org, shown, radio != nil, "")
}
// renderRadioEdit renders the editor (the initial GET and the error re-render):
// the modal fragment for htmx, the standalone page otherwise.
func (s *Handlers) renderRadioEdit(w http.ResponseWriter, r *http.Request, org *store.Org, shown config.RadioDefaults, isSet bool, errMsg string) {
data := map[string]any{
"Org": org,
"Nav": s.orgAdminNav(r, org),
"Action": "/orgs/" + org.Slug + "/config/radio",
"Radio": newRadioFields(shown, true),
"IsSet": isSet,
"Error": errMsg,
}
if r.Header.Get("HX-Request") != "" {
data["Layout"] = "config-radio-modal"
}
s.Render(w, r, "config_radio_edit.html", data)
}
// handleSetRadio saves the org's radio settings, or clears them when the form
// asks to — refused while a profile still references them.
func (s *Handlers) handleSetRadio(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
if r.FormValue("clear") == "1" {
err := s.Store.SetOrgRadio(r.Context(), orgID, nil)
if errors.Is(err, store.ErrRadioInUse) {
current, lerr := s.Store.OrgRadio(r.Context(), orgID)
if lerr != nil || current == nil {
s.ServerError(w, r, "could not load radio settings", lerr)
return
}
s.renderRadioEdit(w, r, org, *current, true,
"A profile uses these settings through its {{ radio.* }} placeholders. Remove them from every profile before clearing the radio settings.")
return
}
if err != nil {
s.ServerError(w, r, "could not clear radio settings", err)
return
}
s.hxRedirect(w, r, "/orgs/"+orgParam(r)+"/config")
return
}
radio, ok := parseRadio(r)
if !ok {
current, err := s.Store.OrgRadio(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load radio settings", err)
return
}
// Re-show what was entered (a value that didn't parse comes back as zero).
s.renderRadioEdit(w, r, org, radio, current != nil, radioFormError)
return
}
if err := s.Store.SetOrgRadio(r.Context(), orgID, &radio); err != nil {
s.ServerError(w, r, "could not save radio settings", err)
return
}
s.hxRedirect(w, r, "/orgs/"+orgParam(r)+"/config")
}
+226
View File
@@ -0,0 +1,226 @@
package core
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/MeshTender/MeshTender/internal/config"
"github.com/MeshTender/MeshTender/internal/store"
)
var uscaRadio = config.RadioDefaults{FreqHz: 910_525_000, BwHz: 62_500, SF: 7, CR: 5}
// TestOrgRadioSettings: an admin sets the org's radio through the modal (the
// firmware's ranges enforced), the Configuration page shows it, a profile can
// then reference it through placeholders and is shown filled in, and the radio
// can't be cleared while that profile uses it. Members can't edit it.
func TestOrgRadioSettings(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, sess := appLogin(t, ts, st, ctx, h.app, "radioadmin")
org, err := st.CreateOrg(ctx, "Radio Org", admin.ID)
if err != nil {
t.Fatal(err)
}
base := "/orgs/" + org.Slug + "/config"
if page := readBody(t, do(t, ts, h.app, base, sess)); !strings.Contains(page, `data-testid="config-radio"`) ||
!strings.Contains(page, "Not set") {
t.Fatal("config page should offer to set the radio")
}
// A profile can't reference a radio the org hasn't set.
bad := postHX(t, ts, h.app, base+"/profiles",
url.Values{"profile_name": {"Std"}, "profile_steps": {"set radio {{radio.freq}},{{radio.bw}},{{radio.sf}},{{radio.cr}}"}}, sess)
if body := readBody(t, bad); bad.Header.Get("HX-Redirect") != "" || !strings.Contains(body, "set them under Radio first") {
t.Fatalf("placeholder profile saved without an org radio: %q", body)
}
// The editor opens on the default preset.
frag := readBody(t, doHX(t, ts, h.app, base+"/radio/edit", sess))
if !strings.Contains(frag, "modal-header") || !strings.Contains(frag, `selected>USA/Canada`) || strings.Contains(frag, "clear-radio") {
t.Fatalf("radio editor fragment: %q", frag)
}
// Out of the firmware's range: the modal stays open with the error.
form := url.Values{"radio_freq_mhz": {"50"}, "radio_bw_khz": {"62.5"}, "radio_sf": {"7"}, "radio_cr": {"5"}}
resp := postHX(t, ts, h.app, base+"/radio", form, sess)
if body := readBody(t, resp); resp.Header.Get("HX-Redirect") != "" || !strings.Contains(body, "150–2500 MHz") {
t.Fatalf("out-of-range radio accepted: %q", body)
}
if r, _ := st.OrgRadio(ctx, org.ID); r != nil {
t.Fatalf("out-of-range radio stored: %v", r)
}
form.Set("radio_freq_mhz", "910.525")
resp = postHX(t, ts, h.app, base+"/radio", form, sess)
resp.Body.Close()
if got := resp.Header.Get("HX-Redirect"); got != base {
t.Fatalf("save HX-Redirect = %q, want %q", got, base)
}
if r, err := st.OrgRadio(ctx, org.ID); err != nil || r == nil || *r != uscaRadio {
t.Fatalf("stored radio = %v, %v", r, err)
}
// Now the placeholder profile saves, canonical, and the page shows it filled in.
ok := postHX(t, ts, h.app, base+"/profiles",
url.Values{"profile_name": {"Std"}, "profile_steps": {"set radio {{radio.freq}},{{radio.bw}},{{radio.sf}},{{radio.cr}}"}}, sess)
ok.Body.Close()
if ok.Header.Get("HX-Redirect") == "" {
t.Fatal("placeholder profile not saved once the radio was set")
}
page := readBody(t, do(t, ts, h.app, base, sess))
for _, want := range []string{"910.525 MHz · BW62.5 · SF7 · CR5", "set radio 910.525,62.5,7,5"} {
if !strings.Contains(page, want) {
t.Errorf("config page missing %q", want)
}
}
if strings.Contains(page, "config-radio-missing") {
t.Error("config page warns about a missing radio that's set")
}
// Clearing is refused while the profile uses it.
clear := postHX(t, ts, h.app, base+"/radio", url.Values{"clear": {"1"}}, sess)
if body := readBody(t, clear); clear.Header.Get("HX-Redirect") != "" || !strings.Contains(body, "A profile uses these settings") {
t.Fatalf("clear while in use: %q", body)
}
// A plain member sees the radio but can't edit it.
member, memberSess := appLogin(t, ts, st, ctx, h.app, "radiomember")
if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
if r := do(t, ts, h.app, base+"/radio/edit", memberSess); r.StatusCode != http.StatusNotFound {
r.Body.Close()
t.Fatalf("member GET editor = %d, want 404", r.StatusCode)
}
form.Set("radio_freq_mhz", "869.525")
r := post(t, ts, h.app, base+"/radio", form, memberSess)
r.Body.Close()
if got, _ := st.OrgRadio(ctx, org.ID); got == nil || *got != uscaRadio {
t.Fatalf("member changed the radio: %v", got)
}
if page := readBody(t, do(t, ts, h.app, base, memberSess)); !strings.Contains(page, "910.525 MHz") || strings.Contains(page, "config-radio-edit") {
t.Error("member view of the radio card")
}
}
// TestOrgRadioFillsAssembledCommands: the console's apply-configuration list and
// serial setup both get the profile with the org's radio filled in; serial setup
// also uses the org's radio when the profile sets none; and a profile left
// referencing an unset radio is shown but never runnable.
func TestOrgRadioFillsAssembledCommands(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "radioassemble")
org, err := st.CreateOrg(ctx, "Assemble Org", owner.ID)
if err != nil {
t.Fatal(err)
}
rep, err := st.CreateRepeater(ctx, &store.Repeater{
OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("c", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatal(err)
}
// Written straight to the store: the editor would refuse a placeholder
// before the radio is set, but a concurrent clear could still leave one.
if _, err := st.CreateProfile(ctx, org.ID, "Std", []store.ConfigStep{
{CommandLine: "set radio {{ radio.freq }},{{ radio.bw }},{{ radio.sf }},{{ radio.cr }}"},
{CommandLine: "set tx 22"},
}); err != nil {
t.Fatal(err)
}
if _, err := st.CreateProfile(ctx, org.ID, "NoRadio", []store.ConfigStep{{CommandLine: "set tx 22"}}); err != nil {
t.Fatal(err)
}
configJSON := "/repeaters/" + rep.PublicID + "/config.json?profile=Std"
setup := func(profile string) *http.Response {
body, _ := json.Marshal(map[string]any{"name": "Hilltop", "orgId": org.ID, "profile": profile})
return jsonPost(t, ts.URL+"/repeaters/setup/commands", h.app, string(body), sess)
}
// No radio yet: shown as written, not runnable, and setup refuses.
cc := getConsoleConfig(t, ts, h.app, configJSON, sess)
if len(cc.Commands) == 0 || cc.Commands[0].Runnable || !strings.Contains(cc.Commands[0].Reason, "radio settings aren't set") {
t.Fatalf("unfilled line = %+v", cc.Commands)
}
if page := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?profile=Std", sess)); !strings.Contains(page, "config-radio-missing") {
t.Error("config page doesn't warn about the unfilled lines")
}
if r := setup("Std"); r.StatusCode != http.StatusConflict {
r.Body.Close()
t.Fatalf("setup with an unfilled profile = %d, want 409", r.StatusCode)
}
if err := st.SetOrgRadio(ctx, org.ID, &uscaRadio); err != nil {
t.Fatal(err)
}
cc = getConsoleConfig(t, ts, h.app, configJSON, sess)
if c := cc.Commands[0]; c.Line != "set radio 910.525,62.5,7,5" || !c.Runnable {
t.Fatalf("filled line = %+v", c)
}
commands := func(profile string) (cmds []string, freq float64) {
r := setup(profile)
defer r.Body.Close()
if r.StatusCode != http.StatusOK {
b, _ := io.ReadAll(r.Body)
t.Fatalf("setup %s = %d: %s", profile, r.StatusCode, b)
}
var out struct {
Commands []string `json:"commands"`
Radio struct {
FreqMHz float64 `json:"freqMhz"`
} `json:"radio"`
}
if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out.Commands, out.Radio.FreqMHz
}
for _, profile := range []string{"Std", "NoRadio"} {
cmds, freq := commands(profile)
if joined := strings.Join(cmds, "\n"); strings.Contains(joined, "{{") || !strings.Contains(joined, "set radio 910.525,62.5,7,5") || freq != 910.525 {
t.Errorf("%s setup: radio %v, commands:\n%s", profile, freq, joined)
}
}
}
// TestParseRadioRanges: the shared radio form parser accepts exactly what the
// firmware's `set radio` does.
func TestParseRadioRanges(t *testing.T) {
t.Parallel()
cases := []struct {
freq, bw, sf, cr string
ok bool
}{
{"910.525", "62.5", "7", "5", true},
{"150", "7", "5", "5", true},
{"2500", "500", "12", "8", true},
{"149.9", "62.5", "7", "5", false},
{"2500.1", "62.5", "7", "5", false},
{"910.525", "6.9", "7", "5", false},
{"910.525", "500.1", "7", "5", false},
{"910.525", "62.5", "4", "5", false},
{"910.525", "62.5", "13", "5", false},
{"910.525", "62.5", "7", "9", false},
{"1e300", "62.5", "7", "5", false},
{"NaN", "62.5", "7", "5", false},
{"910.525", "62.5", "-1", "5", false},
{"", "62.5", "7", "5", false},
}
for _, c := range cases {
r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(url.Values{
"radio_freq_mhz": {c.freq}, "radio_bw_khz": {c.bw}, "radio_sf": {c.sf}, "radio_cr": {c.cr},
}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if _, ok := parseRadio(r); ok != c.ok {
t.Errorf("parseRadio(%s, %s, %s, %s) ok = %v, want %v", c.freq, c.bw, c.sf, c.cr, ok, c.ok)
}
}
}
+5
View File
@@ -106,6 +106,11 @@ func (s *Handlers) consoleConfigJSON(w http.ResponseWriter, r *http.Request) {
allowed[cid] = true
}
runnable := func(line string) (bool, string) {
if store.HasPlaceholders(line) {
// BuildConfigView filled in everything it could; what's left needs
// radio settings the org hasn't set.
return false, "the organization's radio settings aren't set"
}
cmd := resolveCommand(line, catalog)
if cmd == nil {
return false, "not a recognized command"
+19
View File
@@ -64,3 +64,22 @@ func presetIDFor(d config.RadioDefaults) string {
}
return "custom"
}
// radioFields is the data for the shared "radio-fields" template: the preset
// picker, preselected to whichever preset matches radio, plus its values.
type radioFields struct {
Presets []RadioPreset
Selected string
Radio config.RadioDefaults
Named bool // submit the values with the form
}
func newRadioFields(radio config.RadioDefaults, named bool) radioFields {
return radioFields{Presets: radioPresets, Selected: presetIDFor(radio), Radio: radio, Named: named}
}
// defaultRadio is the default preset as radio settings.
func defaultRadio() config.RadioDefaults {
p := defaultPreset()
return config.RadioDefaults{FreqHz: uint32(p.FreqHz), BwHz: uint32(p.BwHz), SF: uint8(p.SF), CR: uint8(p.CR)} //nolint:gosec // G115: preset constants
}
+23 -5
View File
@@ -10,6 +10,7 @@ import (
meshcore "github.com/meshcore-go/meshcore-go"
"github.com/MeshTender/MeshTender/internal/config"
"github.com/MeshTender/MeshTender/internal/store"
"github.com/MeshTender/MeshTender/internal/web"
)
@@ -74,19 +75,31 @@ func (s *Handlers) handleSetupCommands(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no access to that organization", http.StatusForbidden)
return
}
orgRadio, err := s.Store.OrgRadio(r.Context(), req.OrgID)
if err != nil {
s.ServerError(w, r, "could not load radio settings", err)
return
}
if req.Profile != "" {
s2, err := s.profileSteps(r.Context(), req.OrgID, req.Profile)
if err != nil {
s.ServerError(w, r, "could not load profile", err)
return
}
steps = s2
filled, ok := store.ExpandSteps(s2, orgRadio)
if !ok {
http.Error(w, "this profile uses the organization's radio settings, which aren't set", http.StatusConflict)
return
}
steps = filled
}
// Prefer the radio the profile sets on the device; if it sets none, fall
// back to the default preset so the device still ends up tunable (and the
// record radio is accurate).
// Prefer the radio the profile sets on the device; if it sets none, the
// org's radio settings, and failing those the default preset — so the
// device still ends up tunable (and the record radio is accurate).
if rad, ok := parseProfileRadio(profileCommandLines(steps)); ok {
radio = rad
} else if orgRadio != nil {
radio = setupRadioFrom(*orgRadio)
} else {
radio = defaultSetupRadio()
}
@@ -145,6 +158,11 @@ func defaultSetupRadio() setupRadio {
return setupRadio{FreqMHz: float64(p.FreqHz) / 1e6, BwKHz: float64(p.BwHz) / 1e3, SF: p.SF, CR: p.CR}
}
// setupRadioFrom converts an org's Hz-canonical radio settings to command units.
func setupRadioFrom(r config.RadioDefaults) setupRadio {
return setupRadio{FreqMHz: float64(r.FreqHz) / 1e6, BwKHz: float64(r.BwHz) / 1e3, SF: int(r.SF), CR: int(r.CR)}
}
// parseProfileRadio extracts radio settings from a profile's `set radio
// <f,bw,sf,cr>` step, if present. Returns ok=false when the profile sets no
// radio (so the caller can fall back to a default).
@@ -245,7 +263,7 @@ func (s *Handlers) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
}
freq, bw, sf, cr, ok := parseRadioForm(r)
if !ok {
http.Error(w, "radio parameters must be valid numbers", http.StatusBadRequest)
http.Error(w, radioFormError, http.StatusBadRequest)
return
}
+36 -25
View File
@@ -46,15 +46,14 @@ func (s *Handlers) pageAddRepeater(w http.ResponseWriter, r *http.Request) {
method = "kiss"
}
data := map[string]any{
"Step": step,
"Method": method,
"ServerPubKey": s.Identity.PublicKeyHex(),
"SetPermCommand": s.Identity.SetPermCommand(),
"RevokeCommand": s.Identity.RevokePermCommand(),
"Defaults": defaultPreset(),
"Presets": radioPresets,
"DefaultPresetID": defaultPresetID,
"Error": r.URL.Query().Get("error"),
"Step": step,
"Method": method,
"ServerPubKey": s.Identity.PublicKeyHex(),
"SetPermCommand": s.Identity.SetPermCommand(),
"RevokeCommand": s.Identity.RevokePermCommand(),
"Radio": newRadioFields(defaultRadio(), true),
"RadioSerial": newRadioFields(defaultRadio(), false), // serial-setup.js reads the fields by id
"Error": r.URL.Query().Get("error"),
}
if step == "serial" {
orgs := s.setupOrgOptions(r)
@@ -197,7 +196,7 @@ func (s *Handlers) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
freq, bw, sf, cr, ok := parseRadioForm(r)
if !ok {
addErr(w, r, "Radio parameters must be valid numbers.")
addErr(w, r, radioFormError)
return
}
@@ -224,18 +223,31 @@ func (s *Handlers) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/repeaters/"+rep.PublicID+"/added", http.StatusSeeOther)
}
// parseRadioForm reads and validates the radio fields from a repeater form.
// Frequency and bandwidth are entered in MHz/kHz (matching the region presets)
// and stored as Hz.
func parseRadioForm(r *http.Request) (freq, bw int64, sf, cr int16, ok bool) {
// radioFormError is shown when the radio fields don't parse or are out of the
// range the firmware accepts (config.RadioDefaults.Valid).
const radioFormError = "Radio parameters must be ones MeshCore accepts: 150–2500 MHz, 7–500 kHz bandwidth, SF 5–12, CR 5–8."
// parseRadio reads and validates the shared radio fields ("radio-fields"
// template). Frequency and bandwidth are entered in MHz/kHz (matching the
// presets) and returned as Hz.
func parseRadio(r *http.Request) (config.RadioDefaults, bool) {
mhz, e1 := strconv.ParseFloat(r.FormValue("radio_freq_mhz"), 64)
khz, e2 := strconv.ParseFloat(r.FormValue("radio_bw_khz"), 64)
s, e3 := strconv.ParseInt(r.FormValue("radio_sf"), 10, 16)
c, e4 := strconv.ParseInt(r.FormValue("radio_cr"), 10, 16)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || mhz <= 0 || khz <= 0 {
return 0, 0, 0, 0, false
sf, e3 := strconv.ParseUint(r.FormValue("radio_sf"), 10, 8)
cr, e4 := strconv.ParseUint(r.FormValue("radio_cr"), 10, 8)
// Range-check in float space first: converting an out-of-range float to an
// integer is implementation-defined in Go.
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || !(mhz > 0 && mhz < 4000) || !(khz > 0 && khz < 4000) {
return config.RadioDefaults{}, false
}
return int64(math.Round(mhz * 1e6)), int64(math.Round(khz * 1e3)), int16(s), int16(c), true
rd := config.RadioDefaults{FreqHz: uint32(math.Round(mhz * 1e6)), BwHz: uint32(math.Round(khz * 1e3)), SF: uint8(sf), CR: uint8(cr)}
return rd, rd.Valid()
}
// parseRadioForm is parseRadio in a repeater record's column types.
func parseRadioForm(r *http.Request) (freq, bw int64, sf, cr int16, ok bool) {
rd, ok := parseRadio(r)
return int64(rd.FreqHz), int64(rd.BwHz), int16(rd.SF), int16(rd.CR), ok
}
// pageEditRepeater shows the edit form for an owned repeater.
@@ -245,11 +257,10 @@ func (s *Handlers) pageEditRepeater(w http.ResponseWriter, r *http.Request) {
return
}
s.Render(w, r, "edit_repeater.html", map[string]any{
"Repeater": rep,
"Presets": radioPresets,
"SelectedPreset": presetIDFor(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}), //nolint:gosec // G115: radio config value is bounded (preset-constrained)
"RevokeCommand": s.Identity.RevokePermCommand(),
"Error": r.URL.Query().Get("error"),
"Repeater": rep,
"Radio": newRadioFields(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}, true), //nolint:gosec // G115: radio config value is bounded (preset-constrained)
"RevokeCommand": s.Identity.RevokePermCommand(),
"Error": r.URL.Query().Get("error"),
})
}
@@ -271,7 +282,7 @@ func (s *Handlers) handleEditRepeater(w http.ResponseWriter, r *http.Request) {
}
freq, bw, sf, cr, valid := parseRadioForm(r)
if !valid {
editErr("Radio parameters must be valid numbers.")
editErr(radioFormError)
return
}
showOnPublicOrg := r.FormValue("show_on_public_org") != ""
+4 -76
View File
@@ -209,21 +209,7 @@
</div>
<div id="preset-wrap">
<div class="mb-3">
<label class="form-label" for="region">Radio preset</label>
<select class="form-select" id="region">
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.DefaultPresetID}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" $.DefaultPresetID}} selected{{end}}>Custom…</option>
</select>
</div>
<div class="row g-2">
<div class="col-6 col-md-3"><label class="form-label" for="radio_freq_mhz">Freq (MHz)</label><input type="number" step="0.001" class="form-control" id="radio_freq_mhz" value="{{mhz .Defaults.FreqHz}}"></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_bw_khz">Bandwidth (kHz)</label><input type="number" step="0.1" class="form-control" id="radio_bw_khz" value="{{khz .Defaults.BwHz}}"></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_sf">SF</label><input type="number" class="form-control" id="radio_sf" value="{{.Defaults.SF}}" min="5" max="12"></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_cr">CR</label><input type="number" class="form-control" id="radio_cr" value="{{.Defaults.CR}}" min="5" max="8"></div>
</div>
{{template "radio-fields" .RadioSerial}}
</div>
{{/* Location */}}
@@ -265,25 +251,13 @@
<script nonce="{{.Nonce}}">
window.SETUP_ORGS = {{.OrgsJS}};
// Radio preset → fields (shared with the KISS details step's behavior).
function trim(n) { return String(Math.round(n * 1e6) / 1e6); }
function applyRegion() {
var sel = document.getElementById("region");
var opt = sel.options[sel.selectedIndex];
if (!opt.dataset.freq) return;
document.getElementById("radio_freq_mhz").value = trim(opt.dataset.freq / 1e6);
document.getElementById("radio_bw_khz").value = trim(opt.dataset.bw / 1e3);
document.getElementById("radio_sf").value = opt.dataset.sf;
document.getElementById("radio_cr").value = opt.dataset.cr;
}
document.getElementById("region").addEventListener("change", applyRegion);
</script>
{{template "map-styles"}}
{{template "map-scripts"}}
<script src="{{ asset "/static/regionmap.js" }}"></script>
<script src="{{ asset "/static/serial-port.js" }}"></script>
<script src="{{ asset "/static/serial-setup.js" }}"></script>
<script src="{{ asset "/static/radio-preset.js" }}"></script>
{{else}}
{{/* ---- Step 3 (KISS): details for a repeater that's already on the network ---- */}}
@@ -300,21 +274,7 @@
<label class="form-label" for="public_key">Repeater public key (64 hex chars)</label>
<input type="text" class="form-control font-monospace" id="public_key" name="public_key" pattern="[0-9a-fA-F]{64}" placeholder="a1b2…" required>
</div>
<div class="mb-3">
<label class="form-label" for="region">Region preset</label>
<select class="form-select" id="region">
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.DefaultPresetID}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" $.DefaultPresetID}} selected{{end}}>Custom…</option>
</select>
</div>
<div class="row g-2">
<div class="col-6 col-md-3"><label class="form-label" for="radio_freq_mhz">Freq (MHz)</label><input type="number" step="0.001" class="form-control" id="radio_freq_mhz" name="radio_freq_mhz" value="{{mhz .Defaults.FreqHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_bw_khz">Bandwidth (kHz)</label><input type="number" step="0.1" class="form-control" id="radio_bw_khz" name="radio_bw_khz" value="{{khz .Defaults.BwHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_sf">SF</label><input type="number" class="form-control" id="radio_sf" name="radio_sf" value="{{.Defaults.SF}}" min="5" max="12" required></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_cr">CR</label><input type="number" class="form-control" id="radio_cr" name="radio_cr" value="{{.Defaults.CR}}" min="5" max="8" required></div>
</div>
{{template "radio-fields" .Radio}}
<label class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="show_on_public_org" value="1">
@@ -332,39 +292,7 @@
</form>
</div>
</div>
<script nonce="{{.Nonce}}">
var RADIO_FIELDS = ["radio_freq_mhz", "radio_bw_khz", "radio_sf", "radio_cr"];
function trim(n) { return String(Math.round(n * 1e6) / 1e6); }
function applyRegion() {
var sel = document.getElementById("region");
var opt = sel.options[sel.selectedIndex];
if (!opt.dataset.freq) return;
document.getElementById("radio_freq_mhz").value = trim(opt.dataset.freq / 1e6);
document.getElementById("radio_bw_khz").value = trim(opt.dataset.bw / 1e3);
document.getElementById("radio_sf").value = opt.dataset.sf;
document.getElementById("radio_cr").value = opt.dataset.cr;
}
function syncPreset() {
var sel = document.getElementById("region");
var freqHz = Math.round(parseFloat(document.getElementById("radio_freq_mhz").value) * 1e6);
var bwHz = Math.round(parseFloat(document.getElementById("radio_bw_khz").value) * 1e3);
var sf = document.getElementById("radio_sf").value;
var cr = document.getElementById("radio_cr").value;
for (var i = 0; i < sel.options.length; i++) {
var o = sel.options[i];
if (o.dataset.freq && +o.dataset.freq === freqHz && +o.dataset.bw === bwHz &&
o.dataset.sf === sf && o.dataset.cr === cr) {
sel.value = o.value;
return;
}
}
sel.value = "custom";
}
RADIO_FIELDS.forEach(function (id) {
document.getElementById(id).addEventListener("input", syncPreset);
});
document.getElementById("region").addEventListener("change", applyRegion);
</script>
<script src="{{ asset "/static/radio-preset.js" }}"></script>
{{end}}
</div>
</div>
@@ -86,6 +86,7 @@
<div class="mb-1">
<label class="form-label" for="profile_steps">Base settings</label>
<textarea class="form-control font-monospace" id="profile_steps" name="profile_steps" rows="10" spellcheck="false" placeholder="set tx 22&#10;# notes start with #">{{.StepsText}}</textarea>
<small class="form-hint">One command per line; lines starting with <code>#</code> are notes. Put <code>{{"{{ region }}"}}</code> on its own line to insert this organization's region settings there — otherwise they run after all steps.</small>
<small class="form-hint">One command per line; lines starting with <code>#</code> are notes. Put <code>{{"{{ region }}"}}</code> on its own line to insert this organization's region settings there — otherwise they run after all steps.
<code>{{"{{ radio.freq }}"}}</code>, <code>{{"{{ radio.bw }}"}}</code>, <code>{{"{{ radio.sf }}"}}</code> and <code>{{"{{ radio.cr }}"}}</code> stand for this organization's radio settings, e.g. <code>set radio {{"{{ radio.freq }}"}},{{"{{ radio.bw }}"}},{{"{{ radio.sf }}"}},{{"{{ radio.cr }}"}}</code>.</small>
</div>
{{end}}
@@ -0,0 +1,85 @@
{{/* The org radio settings editor. Admins normally reach it as the
config-radio-modal fragment swapped into #config-radio-modal on the
Configuration page; it posts via htmx so a validation error re-renders the
fragment in place and a save sends HX-Redirect back to the config page. The
full page below is the no-JS / direct-link fallback. */}}
{{define "title"}}Radio settings · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
<h1 class="page-title fs-1">Radio settings</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{template "radio-editor-alerts" .}}
<div class="card">
<div class="card-body">
<form method="post" action="{{.Action}}">
{{template "radio-editor-fields" .}}
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">Save radio settings</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
</div>
</form>
{{if .IsSet}}
<form method="post" action="{{.Action}}" class="mt-3">
<input type="hidden" name="clear" value="1">
<button type="submit" class="btn btn-ghost-danger">Clear radio settings</button>
</form>
{{end}}
</div>
</div>
<script src="{{ asset "/static/radio-preset.js" }}"></script>
{{end}}
{{/* config-radio-modal is the htmx fragment swapped into #config-radio-modal.
radio-preset.js is loaded by the Configuration page and delegates, so the
swapped-in preset picker works without a script of its own. */}}
{{define "config-radio-modal"}}
<div class="modal-header">
<h2 class="modal-title">Radio settings</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{template "radio-editor-alerts" .}}
<form method="post" action="{{.Action}}" id="config-radio-form"
hx-post="{{.Action}}" hx-target="#config-radio-modal-content" hx-swap="innerHTML">
{{template "radio-editor-fields" .}}
</form>
</div>
<div class="modal-footer">
{{if .IsSet}}
<form method="post" action="{{.Action}}" class="me-auto"
hx-post="{{.Action}}" hx-target="#config-radio-modal-content" hx-swap="innerHTML">
<input type="hidden" name="clear" value="1">
<button type="submit" class="btn btn-ghost-danger" data-testid="clear-radio">Clear</button>
</form>
{{end}}
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="config-radio-form" class="btn btn-primary" data-testid="save-radio">Save radio settings</button>
</div>
{{end}}
{{define "radio-editor-alerts"}}
{{if .Error}}
<div class="alert alert-danger" role="alert">
<div>{{.Error}}</div>
</div>
{{end}}
{{end}}
{{define "radio-editor-fields"}}
<p class="text-secondary">The LoRa settings this organization's mesh runs on. Profiles can use them instead of
hard-coding values — e.g. <code>set radio {{"{{ radio.freq }}"}},{{"{{ radio.bw }}"}},{{"{{ radio.sf }}"}},{{"{{ radio.cr }}"}}</code> —
and a repeater set up from a profile that doesn't set its radio gets these.</p>
{{template "radio-fields" .Radio}}
{{end}}
+2 -57
View File
@@ -19,21 +19,7 @@
<label class="form-label" for="name">Name</label>
<input type="text" class="form-control" id="name" name="name" value="{{.Repeater.Name}}" required>
</div>
<div class="mb-3">
<label class="form-label" for="region">Region preset</label>
<select class="form-select" id="region">
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.SelectedPreset}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" $.SelectedPreset}} selected{{end}}>Custom…</option>
</select>
</div>
<div class="row g-2">
<div class="col-6 col-md-3"><label class="form-label" for="radio_freq_mhz">Freq (MHz)</label><input type="number" step="0.001" class="form-control" id="radio_freq_mhz" name="radio_freq_mhz" value="{{mhz .Repeater.RadioFreqHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_bw_khz">Bandwidth (kHz)</label><input type="number" step="0.1" class="form-control" id="radio_bw_khz" name="radio_bw_khz" value="{{khz .Repeater.RadioBwHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_sf">SF</label><input type="number" class="form-control" id="radio_sf" name="radio_sf" value="{{.Repeater.RadioSF}}" min="5" max="12" required></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_cr">CR</label><input type="number" class="form-control" id="radio_cr" name="radio_cr" value="{{.Repeater.RadioCR}}" min="5" max="8" required></div>
</div>
{{template "radio-fields" .Radio}}
<label class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="show_on_public_org" value="1"{{if .Repeater.ShowOnPublicOrg}} checked{{end}}>
@@ -54,46 +40,5 @@
{{template "revokedoc" .RevokeCommand}}
</div>
</div>
<script nonce="{{.Nonce}}">
// The form fields display MHz/kHz; preset data-* attributes are Hz-canonical.
var RADIO_FIELDS = ["radio_freq_mhz", "radio_bw_khz", "radio_sf", "radio_cr"];
function trim(n) { return String(Math.round(n * 1e6) / 1e6); } // drop float fuzz
// Seed the radio fields from the chosen preset. Fields stay editable — a
// preset is a starting point, not a lock.
function applyRegion() {
var sel = document.getElementById("region");
var opt = sel.options[sel.selectedIndex];
if (!opt.dataset.freq) return; // "Custom" — leave the current values alone
document.getElementById("radio_freq_mhz").value = trim(opt.dataset.freq / 1e6);
document.getElementById("radio_bw_khz").value = trim(opt.dataset.bw / 1e3);
document.getElementById("radio_sf").value = opt.dataset.sf;
document.getElementById("radio_cr").value = opt.dataset.cr;
}
// When a field is edited by hand, point the dropdown at whichever preset now
// matches (or "Custom" if none do), so it always reflects the actual values.
// Compare in Hz so display rounding can't cause a spurious mismatch.
function syncPreset() {
var sel = document.getElementById("region");
var freqHz = Math.round(parseFloat(document.getElementById("radio_freq_mhz").value) * 1e6);
var bwHz = Math.round(parseFloat(document.getElementById("radio_bw_khz").value) * 1e3);
var sf = document.getElementById("radio_sf").value;
var cr = document.getElementById("radio_cr").value;
for (var i = 0; i < sel.options.length; i++) {
var o = sel.options[i];
if (o.dataset.freq && +o.dataset.freq === freqHz && +o.dataset.bw === bwHz &&
o.dataset.sf === sf && o.dataset.cr === cr) {
sel.value = o.value;
return;
}
}
sel.value = "custom";
}
RADIO_FIELDS.forEach(function (id) {
document.getElementById(id).addEventListener("input", syncPreset);
});
document.getElementById("region").addEventListener("change", applyRegion);
</script>
<script src="{{ asset "/static/radio-preset.js" }}"></script>
{{end}}
+2
View File
@@ -267,6 +267,8 @@ func (s *Handlers) appRouter() chi.Router {
r.Get("/orgs/{id}/config/regions/{rid}/area", s.pageRegionArea)
r.Post("/orgs/{id}/config/regions/{rid}/area", s.handleSaveRegionArea)
r.Post("/orgs/{id}/config/root-flood", s.handleSetRootFlood)
r.Get("/orgs/{id}/config/radio/edit", s.pageRadioEdit)
r.Post("/orgs/{id}/config/radio", s.handleSetRadio)
// Custom org domains are hidden for now — the hosting/TLS infrastructure
// isn't in place yet. Leave the handlers in tree but don't expose them.
// r.Post("/orgs/{id}/domains", s.handleAddOrgDomain)
+89
View File
@@ -0,0 +1,89 @@
//go:build browser
package e2e
import (
"strings"
"testing"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// pickPreset selects a preset in the radio picker the way a user does: set the
// value and fire change (SetValue alone fires nothing).
func pickPreset(id string) chromedp.Action {
return chromedp.Evaluate(`(() => { const s = document.getElementById("radio-preset");
s.value = "`+id+`"; s.dispatchEvent(new Event("change", {bubbles: true})); })()`, nil)
}
// editRadioField types a new value into one radio field, firing input.
func editRadioField(id, value string) chromedp.Action {
return chromedp.Evaluate(`(() => { const f = document.getElementById("`+id+`");
f.value = "`+value+`"; f.dispatchEvent(new Event("input", {bubbles: true})); })()`, nil)
}
// radioState reads the picker and the four fields as "preset freq bw sf cr".
const radioState = `["radio-preset","radio_freq_mhz","radio_bw_khz","radio_sf","radio_cr"]
.map(id => document.getElementById(id).value).join(" ")`
// TestE2EOrgRadioSettings: an admin sets the org's radio from the Configuration
// page. The editor loads into a modal via htmx, and the delegated preset script
// (radio-preset.js) still drives the swapped-in picker under the CSP: a preset
// fills the fields, editing one moves the picker to Custom. Saving shows the
// values on the page. The same shared picker drives the add-repeater form.
func TestE2EOrgRadioSettings(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2eradio")
org, err := srv.store.CreateOrg(srv.ctx, "Radio Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var afterPreset, afterEdit, card string
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(srv.appURL+"/orgs/"+org.Slug+"/config"),
chromedp.WaitVisible(`[data-testid="config-radio-edit"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="config-radio-edit"]`, chromedp.ByQuery),
chromedp.WaitVisible(`#config-radio-modal-content #radio-preset`, chromedp.ByQuery),
pickPreset("ch"),
chromedp.Evaluate(radioState, &afterPreset),
editRadioField("radio_sf", "9"),
chromedp.Evaluate(radioState, &afterEdit),
chromedp.Click(`[data-testid="save-radio"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="config-radio-values"]`, chromedp.ByQuery),
chromedp.Text(`[data-testid="config-radio-values"]`, &card, chromedp.ByQuery),
); err != nil {
t.Fatalf("set org radio: %v", err)
}
if afterPreset != "ch 869.618 62.5 8 8" {
t.Errorf("after picking Switzerland: %q", afterPreset)
}
if afterEdit != "custom 869.618 62.5 9 8" {
t.Errorf("after editing SF: %q", afterEdit)
}
if !strings.Contains(card, "869.618 MHz · BW62.5 · SF9 · CR8") {
t.Errorf("radio card = %q", card)
}
var addForm string
if err := chromedp.Run(bctx,
chromedp.Navigate(srv.appURL+"/repeaters/add?step=details&method=kiss"),
chromedp.WaitVisible(`#radio-preset`, chromedp.ByQuery),
pickPreset("nz"),
chromedp.Evaluate(radioState, &addForm),
); err != nil {
t.Fatalf("add-repeater preset: %v", err)
}
if addForm != "nz 917.375 250 11 5" {
t.Errorf("add-repeater after picking New Zealand: %q", addForm)
}
watch.assertClean(t)
}
+1
View File
@@ -271,6 +271,7 @@ var FirstPartyStatic = []string{
"listfilter.js",
"livereload.js",
"meshmap.js",
"radio-preset.js",
"regionmap.js",
"serial-port.js",
"serial-setup.js",
@@ -0,0 +1,21 @@
-- +goose Up
-- An organization's radio settings: the LoRa parameters its mesh runs on, which
-- config profiles reference through the {{ radio.* }} placeholders instead of
-- hard-coding them (see store.ExpandPlaceholders). Hz-canonical like a
-- repeater's radio_* columns. Optional, but all four or none: a half-set radio
-- can't be turned into a `set radio` command.
ALTER TABLE organizations
ADD COLUMN radio_freq_hz INTEGER,
ADD COLUMN radio_bw_hz INTEGER,
ADD COLUMN radio_sf SMALLINT,
ADD COLUMN radio_cr SMALLINT,
ADD CONSTRAINT organizations_radio_all_or_none
CHECK (num_nulls(radio_freq_hz, radio_bw_hz, radio_sf, radio_cr) IN (0, 4));
-- +goose Down
ALTER TABLE organizations
DROP CONSTRAINT organizations_radio_all_or_none,
DROP COLUMN radio_cr,
DROP COLUMN radio_sf,
DROP COLUMN radio_bw_hz,
DROP COLUMN radio_freq_hz;
+149
View File
@@ -0,0 +1,149 @@
package store
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/MeshTender/MeshTender/internal/config"
)
// Profile command lines can reference the org's radio settings through inline
// placeholders instead of hard-coding them, e.g.
//
// set radio {{ radio.freq }},{{ radio.bw }},{{ radio.sf }},{{ radio.cr }}
//
// They're stored in canonical form (CanonicalizePlaceholders) and filled in by
// ExpandPlaceholders wherever a profile is assembled into commands: the org
// Configuration page, the console's "apply org configuration" and serial setup.
// Unlike RegionMarker, which is a whole step, these live inside a command line.
// placeholderRE matches one {{ ... }} reference, tolerating any inner spacing.
var placeholderRE = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
// radioPlaceholders maps each placeholder name to the value it stands for, in
// the units `set radio` takes (MHz, kHz).
var radioPlaceholders = map[string]func(config.RadioDefaults) string{
"radio.freq": config.RadioDefaults.MHz,
"radio.bw": config.RadioDefaults.KHz,
"radio.sf": func(r config.RadioDefaults) string { return strconv.Itoa(int(r.SF)) },
"radio.cr": func(r config.RadioDefaults) string { return strconv.Itoa(int(r.CR)) },
}
// RadioPlaceholderNames lists the placeholders in the order `set radio` takes
// them, for help text.
var RadioPlaceholderNames = []string{"radio.freq", "radio.bw", "radio.sf", "radio.cr"}
// ErrRadioInUse is returned when clearing an org's radio settings that a
// profile still references.
var ErrRadioInUse = errors.New("store: radio settings are used by a profile")
// CanonicalizePlaceholders rewrites every {{ name }} in line to the stored form
// "{{ radio.freq }}" (single spaces, lower case) and reports any names that
// aren't placeholders, in the form they were typed.
func CanonicalizePlaceholders(line string) (canonical string, unknown []string) {
canonical = placeholderRE.ReplaceAllStringFunc(line, func(m string) string {
name := strings.ToLower(strings.TrimSpace(m[2 : len(m)-2]))
if _, ok := radioPlaceholders[name]; !ok {
unknown = append(unknown, m)
return m
}
return "{{ " + name + " }}"
})
return canonical, unknown
}
// HasPlaceholders reports whether a line references any placeholder.
func HasPlaceholders(line string) bool { return placeholderRE.MatchString(line) }
// ExpandPlaceholders fills a canonical line's placeholders from radio. ok is
// false — and the line comes back untouched — when it has placeholders but the
// org's radio isn't set, or it names one that doesn't exist.
func ExpandPlaceholders(line string, radio *config.RadioDefaults) (string, bool) {
if !HasPlaceholders(line) {
return line, true
}
if radio == nil {
return line, false
}
ok := true
out := placeholderRE.ReplaceAllStringFunc(line, func(m string) string {
value, known := radioPlaceholders[strings.ToLower(strings.TrimSpace(m[2:len(m)-2]))]
if !known {
ok = false
return m
}
return value(*radio)
})
if !ok {
return line, false
}
return out, true
}
// ExpandSteps fills the placeholders in every command step. A step that can't be
// filled is kept as written and ok is false, so a caller can still show the
// profile while refusing to run it.
func ExpandSteps(steps []ConfigStep, radio *config.RadioDefaults) (out []ConfigStep, ok bool) {
ok = true
out = make([]ConfigStep, len(steps))
for i, st := range steps {
out[i] = st
if st.IsComment() || st.IsRegionMarker() {
continue
}
line, filled := ExpandPlaceholders(st.CommandLine, radio)
out[i].CommandLine = line
ok = ok && filled
}
return out, ok
}
// radioPlaceholderLike matches a stored step that references the org's radio.
// Steps are saved canonical, so the prefix is exact.
const radioPlaceholderLike = `%{{ radio.%`
// OrgRadio returns an org's radio settings, or nil when it has none.
func (s *Store) OrgRadio(ctx context.Context, orgID int64) (*config.RadioDefaults, error) {
var freq, bw *int64
var sf, cr *int16
if err := s.pool.QueryRow(ctx,
`SELECT radio_freq_hz, radio_bw_hz, radio_sf, radio_cr FROM organizations WHERE id = $1`, orgID).
Scan(&freq, &bw, &sf, &cr); err != nil {
return nil, notFoundOr(err, "org radio")
}
if freq == nil {
return nil, nil // the CHECK keeps the four columns all-or-none
}
return &config.RadioDefaults{FreqHz: uint32(*freq), BwHz: uint32(*bw), SF: uint8(*sf), CR: uint8(*cr)}, nil //nolint:gosec // G115: written through SetOrgRadio from a Valid radio
}
// SetOrgRadio stores an org's radio settings, or clears them when radio is nil.
// Clearing is refused with ErrRadioInUse while any of the org's profiles
// references them — checked in the same statement, so a profile saved
// concurrently can't slip between the check and the write.
func (s *Store) SetOrgRadio(ctx context.Context, orgID int64, radio *config.RadioDefaults) error {
if radio != nil {
if _, err := s.pool.Exec(ctx,
`UPDATE organizations SET radio_freq_hz = $2, radio_bw_hz = $3, radio_sf = $4, radio_cr = $5 WHERE id = $1`,
orgID, int64(radio.FreqHz), int64(radio.BwHz), int16(radio.SF), int16(radio.CR)); err != nil {
return fmt.Errorf("set org radio: %w", err)
}
return nil
}
tag, err := s.pool.Exec(ctx, `
UPDATE organizations SET radio_freq_hz = NULL, radio_bw_hz = NULL, radio_sf = NULL, radio_cr = NULL
WHERE id = $1 AND NOT EXISTS (
SELECT 1 FROM config_profile_steps s JOIN config_profiles p ON p.id = s.profile_id
WHERE p.org_id = $1 AND s.command_line LIKE $2)`, orgID, radioPlaceholderLike)
if err != nil {
return fmt.Errorf("clear org radio: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRadioInUse
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
package store
import (
"errors"
"reflect"
"testing"
"github.com/MeshTender/MeshTender/internal/config"
)
var usca = config.RadioDefaults{FreqHz: 910_525_000, BwHz: 62_500, SF: 7, CR: 5}
func TestCanonicalizePlaceholders(t *testing.T) {
t.Parallel()
got, unknown := CanonicalizePlaceholders("set radio {{radio.freq}},{{ RADIO.BW }},{{ radio.sf}},{{radio.cr }}")
if want := "set radio {{ radio.freq }},{{ radio.bw }},{{ radio.sf }},{{ radio.cr }}"; got != want || unknown != nil {
t.Fatalf("got %q %v, want %q", got, unknown, want)
}
got, unknown = CanonicalizePlaceholders("set freq {{ radio.frq }} {{region}}")
if got != "set freq {{ radio.frq }} {{region}}" || !reflect.DeepEqual(unknown, []string{"{{ radio.frq }}", "{{region}}"}) {
t.Fatalf("unknown names: got %q %v", got, unknown)
}
}
func TestExpandPlaceholders(t *testing.T) {
t.Parallel()
line := "set radio {{ radio.freq }},{{ radio.bw }},{{ radio.sf }},{{ radio.cr }}"
if got, ok := ExpandPlaceholders(line, &usca); !ok || got != "set radio 910.525,62.5,7,5" {
t.Fatalf("expand = %q, %v", got, ok)
}
// Whole-number values carry no trailing zeros, like the firmware prints them.
r := config.RadioDefaults{FreqHz: 869_000_000, BwHz: 250_000, SF: 11, CR: 8}
if got, _ := ExpandPlaceholders(line, &r); got != "set radio 869,250,11,8" {
t.Fatalf("expand = %q", got)
}
if got, ok := ExpandPlaceholders(line, nil); ok || got != line {
t.Fatalf("no radio: %q, %v; want the line untouched and false", got, ok)
}
if got, ok := ExpandPlaceholders("set tx 22", nil); !ok || got != "set tx 22" {
t.Fatalf("no placeholders needs no radio: %q, %v", got, ok)
}
if _, ok := ExpandPlaceholders("set freq {{ radio.nope }}", &usca); ok {
t.Fatal("an unknown name must not expand")
}
steps := []ConfigStep{{Comment: "{{ radio.freq }} in a note stays"}, {CommandLine: RegionMarker}, {CommandLine: "set freq {{ radio.freq }}"}}
out, ok := ExpandSteps(steps, &usca)
if !ok || out[0].Comment != steps[0].Comment || out[1].CommandLine != RegionMarker || out[2].CommandLine != "set freq 910.525" {
t.Fatalf("ExpandSteps = %+v, %v", out, ok)
}
if steps[2].CommandLine != "set freq {{ radio.freq }}" {
t.Fatal("ExpandSteps modified its input")
}
if _, ok := ExpandSteps(steps, nil); ok {
t.Fatal("ExpandSteps without a radio should report the unfilled step")
}
}
func TestOrgRadio(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "radioowner", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "RadioOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
if r, err := st.OrgRadio(ctx, org.ID); err != nil || r != nil {
t.Fatalf("new org radio = %v, %v; want unset", r, err)
}
if err := st.SetOrgRadio(ctx, org.ID, &usca); err != nil {
t.Fatal(err)
}
if r, err := st.OrgRadio(ctx, org.ID); err != nil || r == nil || *r != usca {
t.Fatalf("radio = %v, %v; want %v", r, err, usca)
}
// A profile referencing the radio blocks clearing it.
pid, err := st.CreateProfile(ctx, org.ID, "P", []ConfigStep{{CommandLine: "set freq {{ radio.freq }}"}})
if err != nil {
t.Fatal(err)
}
if err := st.SetOrgRadio(ctx, org.ID, nil); !errors.Is(err, ErrRadioInUse) {
t.Fatalf("clear while used = %v, want ErrRadioInUse", err)
}
if r, _ := st.OrgRadio(ctx, org.ID); r == nil {
t.Fatal("refused clear still cleared the radio")
}
// Once no profile uses it, it clears.
if err := st.UpdateProfile(ctx, org.ID, pid, "P", []ConfigStep{{CommandLine: "set tx 22"}}); err != nil {
t.Fatal(err)
}
if err := st.SetOrgRadio(ctx, org.ID, nil); err != nil {
t.Fatalf("clear = %v", err)
}
if r, err := st.OrgRadio(ctx, org.ID); err != nil || r != nil {
t.Fatalf("after clear = %v, %v", r, err)
}
// The schema refuses a half-set radio.
if _, err := st.pool.Exec(ctx, `UPDATE organizations SET radio_freq_hz = 910525000 WHERE id = $1`, org.ID); err == nil {
t.Fatal("a radio with only a frequency was accepted")
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ import (
"github.com/MeshTender/MeshTender/internal/store"
)
//go:embed templates/base.html templates/icons.html templates/brand.html templates/org_tabs.html templates/repeater_tabs.html templates/command_grid.html templates/org_access.html templates/org_public.html templates/org_config.html templates/org_repeaters.html templates/error.html
//go:embed templates/base.html templates/icons.html templates/brand.html templates/org_tabs.html templates/repeater_tabs.html templates/command_grid.html templates/org_access.html templates/radio_fields.html templates/org_public.html templates/org_config.html templates/org_repeaters.html templates/error.html
var sharedTemplatesFS embed.FS
// sharedPages are full content pages (not just layout partials) that more than
@@ -261,7 +261,7 @@ func TimeElement(t time.Time, kind string) template.HTML {
}
func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) {
base, err := template.New("").Funcs(templateFuncs).ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/brand.html", "templates/org_tabs.html", "templates/repeater_tabs.html", "templates/command_grid.html", "templates/org_access.html")
base, err := template.New("").Funcs(templateFuncs).ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/brand.html", "templates/org_tabs.html", "templates/repeater_tabs.html", "templates/command_grid.html", "templates/org_access.html", "templates/radio_fields.html")
if err != nil {
return nil, err
}
+16 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"github.com/MeshTender/MeshTender/internal/config"
"github.com/MeshTender/MeshTender/internal/store"
)
@@ -92,6 +93,11 @@ type ConfigView struct {
RootAllowFlood bool // the org root (*) flood policy — not a region row
RegionDef []string // region def/save commands for the previewed location
PreviewActive bool
// Radio is the org's radio settings (nil when unset). SelectedSteps already
// have its {{ radio.* }} placeholders filled in; RadioMissing means some
// couldn't be, so those lines are shown as written and must not be run.
Radio *config.RadioDefaults
RadioMissing bool
}
// MapRegion is one region as the read-only config map draws it — the payload handed
@@ -186,8 +192,13 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
if err != nil {
return ConfigView{}, err
}
radio, err := st.OrgRadio(ctx, orgID)
if err != nil {
return ConfigView{}, err
}
cv := ConfigView{
HasConfig: len(profiles) > 0 || len(regions) > 0,
HasConfig: len(profiles) > 0 || len(regions) > 0 || radio != nil,
Radio: radio,
HasRegions: len(regions) > 0,
PreviewActive: lat != nil && lon != nil,
}
@@ -203,7 +214,10 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
}
}
cv.Selected = profiles[idx].Name
cv.SelectedSteps = profiles[idx].Steps
// Every surface that assembles commands from this view (the page, the
// console) gets them with the org's radio filled in.
filled, ok := store.ExpandSteps(profiles[idx].Steps, radio)
cv.SelectedSteps, cv.RadioMissing = filled, !ok
}
// Frame the location-preview map. Prefer the primary region's geofence; if
// none is set (or it has no shape), fall back to the org's public repeaters;
+52
View File
@@ -0,0 +1,52 @@
// radio-preset.js — behavior for the shared "radio-fields" template: picking a
// preset fills the four radio fields, and editing a field points the picker at
// whichever preset now matches (or "Custom"). Delegated from document, so it
// also covers a form htmx swaps in after load.
(function () {
"use strict";
// drop float fuzz (910525000 / 1e6 must read 910.525)
function trim(n) { return String(Math.round(n * 1e6) / 1e6); }
function field(box, name) { return box.querySelector('[data-radio="' + name + '"]'); }
function applyPreset(sel) {
var box = sel.closest("[data-radio-fields]");
var opt = sel.options[sel.selectedIndex];
if (!box || !opt || !opt.dataset.freq) return; // "Custom" — leave the values alone
field(box, "freq").value = trim(Number(opt.dataset.freq) / 1e6);
field(box, "bw").value = trim(Number(opt.dataset.bw) / 1e3);
field(box, "sf").value = String(opt.dataset.sf);
field(box, "cr").value = String(opt.dataset.cr);
}
// Compare in Hz so display rounding can't cause a spurious mismatch.
function syncPreset(box) {
var sel = box.querySelector("[data-radio-preset]");
if (!sel) return;
var freqHz = Math.round(parseFloat(field(box, "freq").value) * 1e6);
var bwHz = Math.round(parseFloat(field(box, "bw").value) * 1e3);
var sf = String(field(box, "sf").value);
var cr = String(field(box, "cr").value);
for (var i = 0; i < sel.options.length; i++) {
var o = sel.options[i];
if (o.dataset.freq && Number(o.dataset.freq) === freqHz && Number(o.dataset.bw) === bwHz &&
o.dataset.sf === sf && o.dataset.cr === cr) {
sel.value = o.value;
return;
}
}
sel.value = "custom";
}
document.addEventListener("change", function (ev) {
var t = ev.target;
if (t instanceof HTMLSelectElement && t.matches("[data-radio-preset]")) applyPreset(t);
});
document.addEventListener("input", function (ev) {
var t = ev.target;
if (!(t instanceof HTMLInputElement) || !t.matches("[data-radio]")) return;
var box = t.closest("[data-radio-fields]");
if (box) syncPreset(box);
});
})();
+34 -1
View File
@@ -15,7 +15,7 @@
location's region commands spliced in — which is what a repeater owner actually
runs; see ConfigView.AssembledLines. Main comes first in the DOM so a narrow
screen reads content before its picker, matching the other org pages. */}}
{{if or .Config.Profiles .Config.PreviewActive .CanEdit}}
{{if or .Config.Profiles .Config.PreviewActive .Config.Radio .CanEdit}}
<div class="row row-cards">
<div class="col-lg-8">
{{if or .Config.Profiles .Config.PreviewActive}}
@@ -27,6 +27,11 @@
</h2>
</div>
<div class="card-body">{{template "config-lines" .Config.AssembledLines}}</div>
{{if .Config.RadioMissing}}
<div class="card-footer text-danger small" data-testid="config-radio-missing">
Some lines use this organization's radio settings, which aren't set, so they can't be run.
</div>
{{end}}
{{if .Config.PreviewActive}}
{{if not .Config.RegionDef}}
<div class="card-footer text-secondary small">No regions cover this location.</div>
@@ -47,6 +52,28 @@
{{end}}
</div>
<div class="col-lg-4">
{{/* The org's radio: what its profiles' {{ radio.* }} placeholders fill in. */}}
{{if or .Config.Radio .CanEdit}}
<div class="card mb-3" data-testid="config-radio">
<div class="card-header">
<h2 class="card-title">Radio</h2>
{{if .CanEdit}}
<div class="card-actions">
<button type="button" class="btn btn-sm" data-testid="config-radio-edit"
data-bs-toggle="modal" data-bs-target="#config-radio-modal"
hx-get="/orgs/{{.Org.Slug}}/config/radio/edit" hx-target="#config-radio-modal-content" hx-swap="innerHTML">{{template "icon-pencil" "me-1"}}{{if .Config.Radio}}Edit{{else}}Set{{end}}</button>
</div>
{{end}}
</div>
<div class="card-body">
{{with .Config.Radio}}
<div class="fw-bold" data-testid="config-radio-values">{{.MHz}} MHz · BW{{.KHz}} · SF{{.SF}} · CR{{.CR}}</div>
{{else}}
<div class="text-secondary">Not set. Set the radio this organization's mesh runs on so profiles can use it.</div>
{{end}}
</div>
</div>
{{end}}
{{if or .Config.Profiles .CanEdit}}
<div class="card">
<div class="card-header">
@@ -254,11 +281,17 @@
<div class="modal-content" id="config-profile-modal-content"></div>
</div>
</div>
<div class="modal modal-blur fade" id="config-radio-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content" id="config-radio-modal-content"></div>
</div>
</div>
<div class="modal modal-blur fade" id="config-region-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content" id="config-region-modal-content"></div>
</div>
</div>
<script src="{{ asset "/static/radio-preset.js" }}"></script>
{{end}}
{{end}}
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{/* radio-fields is the radio preset picker plus the four LoRa values it fills
in, shared by the add/edit repeater forms and the org radio settings. The
preset is a starting point, not a lock: picking one fills the fields, and
editing a field moves the picker to whichever preset now matches (or
Custom). static/radio-preset.js does both, delegated, so it also works in a
fragment htmx swaps in. Data: Presets, Selected (preset id or "custom"),
Radio (a config.RadioDefaults), Named (submit the fields with the form;
serial setup reads them by id instead). Preset data-* values are in Hz; the
fields show MHz/kHz like the firmware's `set radio`. */}}
{{define "radio-fields"}}
<div data-radio-fields>
<div class="mb-3">
<label class="form-label" for="radio-preset">Radio preset</label>
<select class="form-select" id="radio-preset" data-radio-preset>
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" .Selected}} selected{{end}}>Custom…</option>
</select>
</div>
<div class="row g-2">
<div class="col-6 col-md-3"><label class="form-label" for="radio_freq_mhz">Freq (MHz)</label><input type="number" step="0.001" min="150" max="2500" class="form-control" id="radio_freq_mhz" data-radio="freq" value="{{.Radio.MHz}}"{{if .Named}} name="radio_freq_mhz" required{{end}}></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_bw_khz">Bandwidth (kHz)</label><input type="number" step="0.1" min="7" max="500" class="form-control" id="radio_bw_khz" data-radio="bw" value="{{.Radio.KHz}}"{{if .Named}} name="radio_bw_khz" required{{end}}></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_sf">SF</label><input type="number" min="5" max="12" class="form-control" id="radio_sf" data-radio="sf" value="{{.Radio.SF}}"{{if .Named}} name="radio_sf" required{{end}}></div>
<div class="col-6 col-md-3"><label class="form-label" for="radio_cr">CR</label><input type="number" min="5" max="8" class="form-control" id="radio_cr" data-radio="cr" value="{{.Radio.CR}}"{{if .Named}} name="radio_cr" required{{end}}></div>
</div>
</div>
{{end}}