mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 05:32:05 +00:00
Store locations from explicit get lat/lon
This commit is contained in:
1 parent
b5e911203c
commit
b57e297fd7
4 files changed
+277
No files matched your search
@@ -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.")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user