Store locations from explicit get lat/lon

This commit is contained in:
Jonathon Leight committed 2026-07-08 07:46:39 -04:00
1 parent b5e911203c
commit b57e297fd7
4 files changed
+277

No files matched your search

+34
View File
@@ -391,6 +391,11 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) {
}
}
_ = bridge.Status("reply", reply)
// A "get lat"/"get lon" the user ran themselves carries the repeater's
// current coordinate — capture it so the stored location tracks what the
// device reports, without a separate fetch. Keyed on the resolved catalog
// token (not raw text) so only these two commands trigger it.
s.storeCoordFromReply(ctx, r, cmd, reply, id, bridge)
case errors.Is(err, mesh.ErrNoReply):
_ = bridge.Status("noreply", "No reply received after several tries — the command may still have run.")
default:
@@ -415,3 +420,32 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) {
}
}
}
// storeCoordFromReply persists the coordinate reported by a "get lat"/"get lon"
// command the user ran directly in the console, so the stored location tracks
// what the device reports without a separate fetch. It matches on the resolved
// catalog token (not the raw typed text) so only those two commands trigger it,
// and no-ops for any other command or an unparseable reply. A single "get lat"
// updates only the latitude — SetRepeaterLatitude/Longitude write one column so
// reading one coordinate never clobbers the other. On success it emits a
// "location" status so an open config panel refreshes its region commands.
func (s *Handlers) storeCoordFromReply(ctx context.Context, r *http.Request, cmd *store.Command, reply string, id int64, bridge *wsbridge.Conn) {
value, ok := parseLocationFloat(reply)
if !ok {
return
}
var update func(context.Context, int64, float64) error
switch commandPrefix(cmd.Template) {
case "get lat":
update = s.Store.SetRepeaterLatitude
case "get lon":
update = s.Store.SetRepeaterLongitude
default:
return
}
if err := update(ctx, id, value); err != nil {
web.LogError(r, "console: store coordinate", err, "repeater_id", id, "command_id", cmd.ID)
return
}
_ = bridge.Status("location", "Location updated from the repeater.")
}
+154
View File
@@ -189,6 +189,160 @@ func TestConsoleRoundTrip(t *testing.T) {
}
}
// TestConsoleGetLatUpdatesLocation: running "get lat" directly in the console
// captures the reported coordinate into the stored location, updating only the
// latitude (leaving longitude untouched). The test plays browser, modem, and
// repeater as in TestConsoleRoundTrip.
func TestConsoleGetLatUpdatesLocation(t *testing.T) {
t.Parallel()
st, ctx := coreStore(t)
var masterKey [32]byte
_, _ = rand.Read(masterKey[:])
idSvc, err := identity.LoadOrCreate(ctx, st, masterKey)
if err != nil {
t.Fatalf("identity: %v", err)
}
authSvc, err := auth.New(st, st.Pool(), testAuthConfig())
if err != nil {
t.Fatalf("auth: %v", err)
}
srv, err := NewServer(st, authSvc, idSvc, testConfig())
if err != nil {
t.Fatalf("server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
jar, _ := cookiejar.New(nil)
user := seedSession(t, ts, st, ctx, jar, "geographer")
repeater, err := meshcore.GenerateLocalIdentity(rand.Reader)
if err != nil {
t.Fatalf("repeater identity: %v", err)
}
rep, err := st.CreateRepeater(ctx, &store.Repeater{
OwnerID: user.ID, Name: "Test", PublicKeyHex: repeater.String(),
RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/console/ws"
hdr := http.Header{}
if cs := jar.Cookies(mustURL(t, ts.URL)); len(cs) > 0 {
var parts []string
for _, c := range cs {
parts = append(parts, c.Name+"="+c.Value)
}
hdr.Set("Cookie", strings.Join(parts, "; "))
}
dctx, dcancel := context.WithTimeout(ctx, 5*time.Second)
defer dcancel()
ws, _, err := websocket.Dial(dctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr})
if err != nil {
t.Fatalf("ws dial: %v", err)
}
defer ws.Close(websocket.StatusNormalClosure, "")
rw, rwcancel := context.WithTimeout(ctx, 15*time.Second)
defer rwcancel()
must := func(err error, msg string) {
if err != nil {
t.Fatalf("%s: %v", msg, err)
}
}
must(ws.Write(rw, websocket.MessageText, []byte(`{"type":"ready"}`)), "ready")
must(ws.Write(rw, websocket.MessageText, []byte(`{"type":"cmd","text":"get lat"}`)), "cmd")
serverID := idSvc.Local().Identity
shared, err := repeater.SharedSecret(serverID)
must(err, "shared")
const replyText = "> 37.7749"
// Reply to login (flood) then to the "get lat" command. The server stamps the
// latitude after pushing the "reply" and emits a "location" status; waiting for
// that status guarantees the DB write has completed before we assert.
var buf []byte
loggedIn := false
cmdReplied := false
located := false
for !located {
typ, data, err := ws.Read(rw)
must(err, "ws read")
if typ == websocket.MessageText {
var m struct{ State, Message string }
if json.Unmarshal(data, &m) == nil {
if m.State == "error" || m.State == "denied" || m.State == "noreply" {
t.Fatalf("unexpected status %q: %s", m.State, m.Message)
}
if m.State == "location" {
located = true
}
}
continue
}
buf = append(buf, data...)
frames, rest, _ := hardware.ExtractFrames(buf)
buf = rest
for _, f := range frames {
if f.Command != hardware.KISS_CMD_DATA {
continue
}
pkt, err := meshcore.PacketFromBytes(f.Data)
if err != nil {
continue
}
switch pkt.PayloadType() {
case meshcore.PayloadTypeAnonReq:
if loggedIn {
continue
}
loggedIn = true
resp := make([]byte, 13)
binary.LittleEndian.PutUint32(resp[:4], 1_700_002_000)
resp[6] = 1 // admin
resp[7] = 3
body := append([]byte{0x00, meshcore.PayloadTypeResponse}, resp...)
enc, _ := meshcore.EncryptThenMAC(shared, body)
p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]}
payload, _ := p.ToBytes()
lp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypePath, 0), Payload: payload}
raw, _ := lp.ToBytes()
must(ws.Write(rw, websocket.MessageBinary, hardware.EncodeDataFrame(raw)), "login reply")
case meshcore.PayloadTypeTxtMsg:
if cmdReplied {
continue
}
tm, err := meshcore.TextMessageFromBytes(pkt.Payload)
must(err, "parse text message")
plain := tm.Decrypt(shared)
if plain == nil || !strings.HasPrefix(string(plain[5:]), "get lat") {
t.Fatalf("decoded command = %q, want get lat", string(plain[5:]))
}
cmdReplied = true
replyPlain := meshcore.BuildTextPlaintext(time.Unix(1_700_002_000, 0), 1<<2, []byte(replyText))
rtm, err := meshcore.NewTextMessage(repeater, serverID, replyPlain, shared)
must(err, "reply text message")
payload, _ := rtm.ToBytes()
rpkt := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeTxtMsg, 0), Payload: payload}
raw, _ := rpkt.ToBytes()
must(ws.Write(rw, websocket.MessageBinary, hardware.EncodeDataFrame(raw)), "send reply")
}
}
}
// Only the latitude should be stored; longitude stays NULL (we never read it).
got, err := st.GetRepeaterForUser(ctx, user.ID, rep.ID)
must(err, "reload repeater")
if got.Latitude == nil || *got.Latitude != 37.7749 {
t.Fatalf("stored latitude = %v, want 37.7749", got.Latitude)
}
if got.Longitude != nil {
t.Fatalf("stored longitude = %v, want nil (get lat must not touch longitude)", got.Longitude)
}
}
// TestConsoleAuditFailureRefusesCommand: if a command can't be recorded to the
// audit log, the console must refuse to send it to the device rather than execute
// an unlogged command. We drop command_log so LogCommand fails, then send a
+23
View File
@@ -275,6 +275,29 @@ func (s *Store) SetRepeaterLocation(ctx context.Context, repeaterID int64, lat,
return nil
}
// SetRepeaterLatitude stores only a repeater's latitude, leaving the longitude
// untouched. Used when the console reads a single coordinate ("get lat"), which
// arrives independently of the other and must not clobber it.
func (s *Store) SetRepeaterLatitude(ctx context.Context, repeaterID int64, lat float64) error {
_, err := s.pool.Exec(ctx,
`UPDATE repeaters SET latitude = $2 WHERE id = $1`, repeaterID, lat)
if err != nil {
return fmt.Errorf("set latitude: %w", err)
}
return nil
}
// SetRepeaterLongitude stores only a repeater's longitude, leaving the latitude
// untouched. See SetRepeaterLatitude.
func (s *Store) SetRepeaterLongitude(ctx context.Context, repeaterID int64, lon float64) error {
_, err := s.pool.Exec(ctx,
`UPDATE repeaters SET longitude = $2 WHERE id = $1`, repeaterID, lon)
if err != nil {
return fmt.Errorf("set longitude: %w", err)
}
return nil
}
// DeleteRepeaterOwned deletes a repeater only if owned by ownerID.
func (s *Store) DeleteRepeaterOwned(ctx context.Context, ownerID, repeaterID int64) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM repeaters WHERE id = $1 AND owner_id = $2`, repeaterID, ownerID)
+66
View File
@@ -0,0 +1,66 @@
package store
import (
"strings"
"testing"
)
// TestSetRepeaterCoordIndependent pins that SetRepeaterLatitude and
// SetRepeaterLongitude each write only their own column. The console reads the
// two coordinates independently ("get lat" / "get lon"), so writing one must
// never clobber the other — otherwise a single "get lat" would blank the stored
// longitude.
func TestSetRepeaterCoordIndependent(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "owner", "")
if err != nil {
t.Fatalf("create owner: %v", err)
}
rep, err := st.CreateRepeater(ctx, &Repeater{
OwnerID: owner.ID, Name: "rep", PublicKeyHex: strings.Repeat("a", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
read := func() (lat, lon *float64) {
t.Helper()
r, err := st.GetRepeaterOwned(ctx, owner.ID, rep.ID)
if err != nil {
t.Fatalf("get repeater: %v", err)
}
return r.Latitude, r.Longitude
}
// A fresh repeater has neither coordinate.
if lat, lon := read(); lat != nil || lon != nil {
t.Fatalf("new repeater should be unlocated, got lat=%v lon=%v", lat, lon)
}
// Setting latitude alone leaves longitude NULL.
if err := st.SetRepeaterLatitude(ctx, rep.ID, 37.7749); err != nil {
t.Fatalf("set latitude: %v", err)
}
if lat, lon := read(); lat == nil || *lat != 37.7749 || lon != nil {
t.Fatalf("after set latitude: got lat=%v lon=%v, want lat=37.7749 lon=nil", lat, lon)
}
// Setting longitude alone must not disturb the stored latitude.
if err := st.SetRepeaterLongitude(ctx, rep.ID, -122.4194); err != nil {
t.Fatalf("set longitude: %v", err)
}
if lat, lon := read(); lat == nil || *lat != 37.7749 || lon == nil || *lon != -122.4194 {
t.Fatalf("after set longitude: got lat=%v lon=%v, want lat=37.7749 lon=-122.4194", lat, lon)
}
// Overwriting latitude keeps longitude intact.
if err := st.SetRepeaterLatitude(ctx, rep.ID, 40.0); err != nil {
t.Fatalf("overwrite latitude: %v", err)
}
if lat, lon := read(); lat == nil || *lat != 40.0 || lon == nil || *lon != -122.4194 {
t.Fatalf("after overwrite latitude: got lat=%v lon=%v, want lat=40.0 lon=-122.4194", lat, lon)
}
}