From 9274acfd5894d38049320072dc6ce37a707d2836 Mon Sep 17 00:00:00 2001 From: Michael Beutler <35310806+michaelbeutler@users.noreply.github.com> Date: Thu, 28 May 2026 12:25:39 +0200 Subject: [PATCH] fix(loracloud): bump request timestamp by 1s on GNSS-NG EoG EoG uplinks share the same RX second as the prior captures in their group, so Traxmate's solver can't order them. Adding 1s to the request timestamp when the GNSS-NG header has EoG set keeps the EoG message strictly after the rest of the group without changing the response timestamp surface. --- .secrets.baseline | 6 +- pkg/solver/loracloud/loracloud.go | 8 ++ pkg/solver/loracloud/loracloud_test.go | 128 +++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 42f6749..24b470b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2212,7 +2212,7 @@ "filename": "pkg/solver/loracloud/loracloud_test.go", "hashed_secret": "884f72cf02528dcd37a031e2af8575273d4394e2", "is_verified": false, - "line_number": 228, + "line_number": 356, "is_secret": false }, { @@ -2220,7 +2220,7 @@ "filename": "pkg/solver/loracloud/loracloud_test.go", "hashed_secret": "56a889aac5ddc1816be3e5604074cf3e50c70500", "is_verified": false, - "line_number": 351, + "line_number": 479, "is_secret": false } ], @@ -2235,5 +2235,5 @@ } ] }, - "generated_at": "2026-04-28T18:24:36Z" + "generated_at": "2026-05-28T10:25:32Z" } diff --git a/pkg/solver/loracloud/loracloud.go b/pkg/solver/loracloud/loracloud.go index 5b32a47..47084de 100644 --- a/pkg/solver/loracloud/loracloud.go +++ b/pkg/solver/loracloud/loracloud.go @@ -230,6 +230,14 @@ func (m LoracloudClient) DeliverUplinkMessage(devEui string, uplinkMsg UplinkMsg return nil, err } + // EoG uplinks share the same RX second as the prior captures in their group. + // Bumping the request timestamp by 1s lets Traxmate's GNSS-NG solver order the + // EoG message after the rest of the group. + if header.EndOfGroup && uplinkMsg.Timestamp != nil { + adjusted := *uplinkMsg.Timestamp + 1 + uplinkMsg.Timestamp = &adjusted + } + url := fmt.Sprintf("%v/api/v1/device/send", m.BaseUrl) // format devEui to match ^([0-9a-fA-F]){2}(-([0-9a-fA-F]){2}){7}$ diff --git a/pkg/solver/loracloud/loracloud_test.go b/pkg/solver/loracloud/loracloud_test.go index 8113969..672073b 100644 --- a/pkg/solver/loracloud/loracloud_test.go +++ b/pkg/solver/loracloud/loracloud_test.go @@ -2,6 +2,7 @@ package loracloud import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -16,6 +17,8 @@ import ( "go.uber.org/zap" ) +func float64Ptr(v float64) *float64 { return &v } + func startMockServer(handler http.Handler) *httptest.Server { server := httptest.NewServer(handler) return server @@ -208,6 +211,131 @@ func TestDeliverUplinkMessage(t *testing.T) { }) } +func TestDeliverUplinkMessage_EndOfGroupTimestampAdjustment(t *testing.T) { + successResponse := []byte(`{ + "result": { + "deveui": "01-23-45-67-89-AB-CD-EF", + "position_solution": { + "algorithm_type": "gnssng", + "llh": [51.49278, 0.0212, 83.93], + "accuracy": 20.7, + "gdop": 2.48, + "capture_time_utc": 1722433373.18046 + }, + "operation": "gnss" + } + }`) + + tests := []struct { + name string + payload string + inputTimestamp *float64 + wantTimestamp *float64 + }{ + { + name: "end of group bumps timestamp by 1s", + payload: "80", + inputTimestamp: float64Ptr(1722433373), + wantTimestamp: float64Ptr(1722433374), + }, + { + name: "not end of group leaves timestamp untouched", + payload: "01", + inputTimestamp: float64Ptr(1722433373), + wantTimestamp: float64Ptr(1722433373), + }, + { + name: "end of group with nil timestamp stays nil", + payload: "80", + inputTimestamp: nil, + wantTimestamp: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedTimestamp *float64 + var captured bool + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/device/send", func(w http.ResponseWriter, r *http.Request) { + var body struct { + Uplink struct { + Timestamp *float64 `json:"timestamp"` + } `json:"uplink"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + capturedTimestamp = body.Uplink.Timestamp + captured = true + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(successResponse) + }) + + server := startMockServer(mux) + defer server.Close() + middleware, err := NewLoracloudClient(context.TODO(), "access_token", zap.NewExample(), WithBaseUrl(server.URL)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err = middleware.DeliverUplinkMessage("0123456789ABCDEF", UplinkMsg{ + MsgType: "updf", + FCount: 1, + Port: 192, + Payload: tt.payload, + Timestamp: tt.inputTimestamp, + }) + assert.NoError(t, err) + assert.True(t, captured, "expected request to reach the mock server") + assert.Equal(t, tt.wantTimestamp, capturedTimestamp) + }) + } +} + +func TestDeliverUplinkMessage_EndOfGroupDoesNotMutateCallerTimestamp(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/device/send", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "result": { + "deveui": "01-23-45-67-89-AB-CD-EF", + "position_solution": { + "algorithm_type": "gnssng", + "llh": [51.49278, 0.0212, 83.93], + "accuracy": 20.7, + "gdop": 2.48, + "capture_time_utc": 1722433373.18046 + }, + "operation": "gnss" + } + }`)) + }) + + server := startMockServer(mux) + defer server.Close() + middleware, err := NewLoracloudClient(context.TODO(), "access_token", zap.NewExample(), WithBaseUrl(server.URL)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + original := float64(1722433373) + uplinkMsg := UplinkMsg{ + MsgType: "updf", + FCount: 1, + Port: 192, + Payload: "80", + Timestamp: &original, + } + + _, err = middleware.DeliverUplinkMessage("0123456789ABCDEF", uplinkMsg) + assert.NoError(t, err) + assert.Equal(t, float64(1722433373), *uplinkMsg.Timestamp, "caller's timestamp pointer must not be mutated") +} + func TestResponseVariants(t *testing.T) { type Expected = struct { timestamp *time.Time