diff --git a/internal/config/config.go b/internal/config/config.go index 0f78511..857ab67 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) { diff --git a/internal/core/config_profile.go b/internal/core/config_profile.go index 5dbe1d0..e537e69 100644 --- a/internal/core/config_profile.go +++ b/internal/core/config_profile.go @@ -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, diff --git a/internal/core/config_profile_test.go b/internal/core/config_profile_test.go index 23c246f..7b8a65e 100644 --- a/internal/core/config_profile_test.go +++ b/internal/core/config_profile_test.go @@ -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) + } + } +} diff --git a/internal/core/config_radio.go b/internal/core/config_radio.go new file mode 100644 index 0000000..66af333 --- /dev/null +++ b/internal/core/config_radio.go @@ -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") +} diff --git a/internal/core/config_radio_test.go b/internal/core/config_radio_test.go new file mode 100644 index 0000000..4680555 --- /dev/null +++ b/internal/core/config_radio_test.go @@ -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) + } + } +} diff --git a/internal/core/console_config.go b/internal/core/console_config.go index b1ebbc6..2d7e266 100644 --- a/internal/core/console_config.go +++ b/internal/core/console_config.go @@ -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" diff --git a/internal/core/presets.go b/internal/core/presets.go index d57895e..f3ba4f7 100644 --- a/internal/core/presets.go +++ b/internal/core/presets.go @@ -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 +} diff --git a/internal/core/repeater_setup.go b/internal/core/repeater_setup.go index 81a5136..e075364 100644 --- a/internal/core/repeater_setup.go +++ b/internal/core/repeater_setup.go @@ -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 // ` 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 } diff --git a/internal/core/repeaters.go b/internal/core/repeaters.go index f39e2b1..ee0a621 100644 --- a/internal/core/repeaters.go +++ b/internal/core/repeaters.go @@ -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") != "" diff --git a/internal/core/templates/add_repeater.html b/internal/core/templates/add_repeater.html index aaec3c2..37a38fc 100644 --- a/internal/core/templates/add_repeater.html +++ b/internal/core/templates/add_repeater.html @@ -209,21 +209,7 @@
-
- - -
-
-
-
-
-
-
+ {{template "radio-fields" .RadioSerial}}
{{/* Location */}} @@ -265,25 +251,13 @@ {{template "map-styles"}} {{template "map-scripts"}} + {{else}} {{/* ---- Step 3 (KISS): details for a repeater that's already on the network ---- */}} @@ -300,21 +274,7 @@ -
- - -
-
-
-
-
-
-
+ {{template "radio-fields" .Radio}}