Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ require (
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.27.0
gotest.tools/v3 v3.5.2
)

require (
Expand All @@ -33,7 +32,6 @@ require (
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,3 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
2 changes: 2 additions & 0 deletions pkg/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ var (
ErrValidationFailed = errors.New("validation failed")

ErrSolverFailed = errors.New("solver failed")

ErrGNSSNGHeaderByteMissing = errors.New("GNSS-NG header byte missing")
)

// WrapError wraps two errors into a single error, combining the parent and child errors.
Expand Down
31 changes: 31 additions & 0 deletions pkg/common/gnssng.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package common

// Documentation:
// https://web.archive.org/web/20250820082113/https://www.semtech.com/loracloud-documentation/mdmsvc.html#lora-edge-gnss-ng-nav-group-positioning-protocol

type GNSSNGHeader struct {
EndOfGroup bool
ReservedForFutureUse uint8
GroupToken uint8
}

const (
GNSSNGHeaderEndOfGroupMask = 0b1000_0000
GNSSNGHeaderReservedForFutureUseMask = 0b0100_0000
GNSSNGHeaderGroupTokenMask = 0b0011_1111
)

func DecodeGNSSNGHeader(payload []byte) (GNSSNGHeader, error) {
if len(payload) < 1 {
return GNSSNGHeader{}, ErrGNSSNGHeaderByteMissing
}

headerByte := payload[0]
header := GNSSNGHeader{
EndOfGroup: (headerByte & GNSSNGHeaderEndOfGroupMask) != 0,
ReservedForFutureUse: (headerByte & GNSSNGHeaderReservedForFutureUseMask) >> 6,
GroupToken: (headerByte & GNSSNGHeaderGroupTokenMask),
}

return header, nil
}
75 changes: 75 additions & 0 deletions pkg/common/gnssng_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package common

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestDecodeGNSSNGHeader(t *testing.T) {
tests := []struct {
name string
payload []byte
header GNSSNGHeader
err error
}{
{
name: "empty",
payload: []byte{},
header: GNSSNGHeader{
EndOfGroup: false,
ReservedForFutureUse: 0,
GroupToken: 0,
},
err: ErrGNSSNGHeaderByteMissing,
},
{
name: "end of group",
payload: []byte{0b1000_0000},
header: GNSSNGHeader{
EndOfGroup: true,
ReservedForFutureUse: 0,
GroupToken: 0,
},
err: nil,
},
{
name: "group token",
payload: []byte{0b0001_1111},
header: GNSSNGHeader{
EndOfGroup: false,
ReservedForFutureUse: 0,
GroupToken: 0b0001_1111,
},
err: nil,
},
{
name: "end of group with group token (31)",
payload: []byte{0b1000_0000 | 0b0001_1111},
header: GNSSNGHeader{
EndOfGroup: true,
ReservedForFutureUse: 0,
GroupToken: 31,
},
err: nil,
},
{
name: "end of group with group token (9)",
payload: []byte{0b1000_0000 | 0b0000_1001},
header: GNSSNGHeader{
EndOfGroup: true,
ReservedForFutureUse: 0,
GroupToken: 9,
},
err: nil,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
header, err := DecodeGNSSNGHeader(test.payload)
assert.Equal(t, header, test.header)
assert.Equal(t, err, test.err)
})
}
}
81 changes: 56 additions & 25 deletions pkg/solver/loracloud/loracloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/go-playground/validator"
"github.com/truvami/decoder/pkg/common"
"github.com/truvami/decoder/pkg/decoder"
"github.com/truvami/decoder/pkg/solver"
"go.uber.org/zap"
Expand Down Expand Up @@ -140,7 +141,17 @@ func (m LoracloudClient) Solve(ctx context.Context, payload string) (*decoder.De
if err != nil {
return nil, fmt.Errorf("error delivering uplink message: %v", err)
}
return decoder.NewDecodedUplink([]decoder.Feature{decoder.FeatureTimestamp, decoder.FeatureGNSS, decoder.FeatureBuffered}, decodedData), err

features := []decoder.Feature{}
if decodedData.HasValidPositionResolution() {
features = []decoder.Feature{
decoder.FeatureTimestamp,
decoder.FeatureGNSS,
decoder.FeatureBuffered,
}
}

return decoder.NewDecodedUplink(features, decodedData), err
}

func (m LoracloudClient) post(url string, body []byte) (*http.Response, error) {
Expand Down Expand Up @@ -209,6 +220,16 @@ func (m LoracloudClient) DeliverUplinkMessage(devEui string, uplinkMsg UplinkMsg
return nil, fmt.Errorf("error validating uplink message: %v", err)
}

// Decode the GNSS-NG header
bytes, err := common.HexStringToBytes(uplinkMsg.Payload)
if err != nil {
return nil, err
}
header, err := common.DecodeGNSSNGHeader(bytes)
if err != nil {
return nil, err
}

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}$
Expand Down Expand Up @@ -290,31 +311,27 @@ func (m LoracloudClient) DeliverUplinkMessage(devEui string, uplinkMsg UplinkMsg
// remove the '-' from the devEui
uplinkResponse.Result.Deveui = strings.ReplaceAll(uplinkResponse.Result.Deveui, "-", "")

// Increment metrics
metricDevEui := uplinkResponse.Result.Deveui
if uplinkResponse.GetTimestamp() == nil {
loracloudPositionEstimateNoCapturedAtSetCounter.WithLabelValues(metricDevEui).Inc()
}
if uplinkResponse.GetLatitude() == 0 &&
uplinkResponse.GetLongitude() == 0 {
loracloudPositionEstimateZeroCoordinatesSetCounter.WithLabelValues(metricDevEui).Inc()
}
if uplinkResponse.GetTimestamp() == nil &&
uplinkResponse.GetLatitude() != 0 &&
uplinkResponse.GetLongitude() != 0 {
loracloudPositionEstimateNoCapturedAtSetWithValidCoordinatesCounter.WithLabelValues(metricDevEui).Inc()
}
if uplinkResponse.GetTimestamp() != nil &&
uplinkResponse.GetLatitude() != 0 &&
uplinkResponse.GetLongitude() != 0 {
loracloudPositionEstimateValidCounter.WithLabelValues(metricDevEui).Inc()
}
// We sent a position with the EndOfGroup GNSS-NG header flag set - we expect a position resolution
if header.EndOfGroup {
metricDevEui := uplinkResponse.Result.Deveui

if uplinkResponse.GetTimestamp() == nil &&
uplinkResponse.GetLatitude() == 0 &&
uplinkResponse.GetLongitude() == 0 {
loracloudPositionEstimateNoCapturedAtSetAndZeroCoordinatesSetCounter.WithLabelValues(metricDevEui).Inc()
return nil, ErrPositionResolutionIsEmpty
if uplinkResponse.GetTimestamp() == nil {
loracloudPositionEstimateNoCapturedAtSetCounter.WithLabelValues(metricDevEui).Inc()
}
if !uplinkResponse.HasValidCoordinates() {
loracloudPositionEstimateZeroCoordinatesSetCounter.WithLabelValues(metricDevEui).Inc()
}
if uplinkResponse.GetTimestamp() == nil &&
uplinkResponse.HasValidCoordinates() {
loracloudPositionEstimateNoCapturedAtSetWithValidCoordinatesCounter.WithLabelValues(metricDevEui).Inc()
}

if uplinkResponse.HasValidPositionResolution() {
loracloudPositionEstimateValidCounter.WithLabelValues(metricDevEui).Inc()
} else {
loracloudPositionEstimateInvalidCounter.WithLabelValues(metricDevEui).Inc()
return nil, ErrPositionResolutionIsEmpty
}
}

return &uplinkResponse, nil
Expand Down Expand Up @@ -497,6 +514,20 @@ func (p UplinkMsgResponse) GetAltitude() float64 {
return 0
}

func (p UplinkMsgResponse) HasValidCoordinates() bool {
if p.GetLatitude() == 0 && p.GetLongitude() == 0 {
return false
}
return true
}

func (p UplinkMsgResponse) HasValidPositionResolution() bool {
if p.GetTimestamp() != nil && p.HasValidCoordinates() {
return true
}
return false
}

func (p UplinkMsgResponse) GetAccuracy() *float64 {
return &p.Result.PositionSolution.Accuracy
}
Expand Down
41 changes: 14 additions & 27 deletions pkg/solver/loracloud/loracloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ func TestResponseVariants(t *testing.T) {
name string
result []byte
expected Expected
err error
}{
{
name: "normal response",
Expand All @@ -241,6 +242,7 @@ func TestResponseVariants(t *testing.T) {
longitude: 0.0212,
altitude: 83.93,
},
err: nil,
},
{
name: "llh empty array",
Expand All @@ -257,12 +259,8 @@ func TestResponseVariants(t *testing.T) {
"operation": "gnss"
}
}`),
expected: Expected{
timestamp: common.TimePointer(1722433373.18046),
latitude: 0,
longitude: 0,
altitude: 0,
},
expected: Expected{},
err: ErrPositionResolutionIsEmpty,
},
{
name: "captured at null and no algorithm type",
Expand All @@ -278,12 +276,8 @@ func TestResponseVariants(t *testing.T) {
"operation": "gnss"
}
}`),
expected: Expected{
timestamp: nil,
latitude: 51.49278,
longitude: 0.0212,
altitude: 83.93,
},
expected: Expected{},
err: ErrPositionResolutionIsEmpty,
},
{
name: "captured at null and gnss ng algorithm type",
Expand Down Expand Up @@ -358,21 +352,14 @@ func TestResponseVariants(t *testing.T) {
}

response, err := middleware.DeliverUplinkMessage(devEui, uplinkMsg)
if err != nil {
t.Fatalf("error %s", err)
}

if !common.TimePointerCompare(response.GetTimestamp(), test.expected.timestamp) {
t.Fatalf("expected timestamp %s got %s", test.expected.timestamp, response.GetTimestamp())
}
if response.GetLatitude() != test.expected.latitude {
t.Fatalf("expected latitude %f got %f", test.expected.latitude, response.GetLatitude())
}
if response.GetLongitude() != test.expected.longitude {
t.Fatalf("expected longitude %f got %f", test.expected.longitude, response.GetLongitude())
}
if response.GetAltitude() != test.expected.altitude {
t.Fatalf("expected altitude %f got %f", test.expected.altitude, response.GetAltitude())
if test.err != nil {
assert.ErrorIs(t, err, test.err)
} else {
assert.NoError(t, err)
assert.Equal(t, test.expected.timestamp, response.GetTimestamp())
assert.Equal(t, test.expected.latitude, response.GetLatitude())
assert.Equal(t, test.expected.longitude, response.GetLongitude())
assert.Equal(t, test.expected.altitude, response.GetAltitude())
}
})
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/solver/loracloud/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ var (
Name: "truvami_loracloud_position_estimate_zero_coordinates_set_total",
Help: "The total number of position estimate responses where the coordinates are set to 0",
}, []string{"devEUI"})
loracloudPositionEstimateNoCapturedAtSetAndZeroCoordinatesSetCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "truvami_loracloud_position_estimate_no_captured_at_set_and_zero_coordinates_set_total",
Help: "The total number of position estimate responses where the captured at (UTC) timestamp is not set and the coordinates are set to 0",
}, []string{"devEUI"})
loracloudPositionEstimateNoCapturedAtSetWithValidCoordinatesCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "truvami_loracloud_position_estimate_no_captured_at_set_with_valid_coordinates_total",
Help: "The total number of position estimate responses where the captured at (UTC) timestamp is not set and the coordinates are valid",
Expand All @@ -26,4 +22,8 @@ var (
Name: "truvami_loracloud_position_estimate_valid_total",
Help: "The total number of position estimate responses where the captured at (UTC) timestamp is set and the coordinates are valid",
}, []string{"devEUI"})
loracloudPositionEstimateInvalidCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "truvami_loracloud_position_estimate_invalid_total",
Help: "The total number of position estimate responses where the position resolution is invalid",
}, []string{"devEUI"})
)