mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-11-05 19:57:18 +00:00
Add org-level radio config
This commit is contained in:
1 parent
6f82b437f3
commit
ef0ca97e87
24 files changed
+1098
-194
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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") != ""
|
||||
|
||||
@@ -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 # 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}}
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user