diff --git a/go.mod b/go.mod index 2ab0821..ac13fa4 100644 --- a/go.mod +++ b/go.mod @@ -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 ( @@ -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 diff --git a/go.sum b/go.sum index 5421bd2..394c5fc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/common/errors.go b/pkg/common/errors.go index 725b8e9..b061058 100644 --- a/pkg/common/errors.go +++ b/pkg/common/errors.go @@ -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. diff --git a/pkg/common/gnssng.go b/pkg/common/gnssng.go new file mode 100644 index 0000000..ca340a8 --- /dev/null +++ b/pkg/common/gnssng.go @@ -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 +} diff --git a/pkg/common/gnssng_test.go b/pkg/common/gnssng_test.go new file mode 100644 index 0000000..04a1256 --- /dev/null +++ b/pkg/common/gnssng_test.go @@ -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) + }) + } +} diff --git a/pkg/solver/loracloud/loracloud.go b/pkg/solver/loracloud/loracloud.go index eea5513..693cd53 100644 --- a/pkg/solver/loracloud/loracloud.go +++ b/pkg/solver/loracloud/loracloud.go @@ -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" @@ -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) { @@ -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}$ @@ -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 @@ -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 } diff --git a/pkg/solver/loracloud/loracloud_test.go b/pkg/solver/loracloud/loracloud_test.go index 21feb69..8113969 100644 --- a/pkg/solver/loracloud/loracloud_test.go +++ b/pkg/solver/loracloud/loracloud_test.go @@ -219,6 +219,7 @@ func TestResponseVariants(t *testing.T) { name string result []byte expected Expected + err error }{ { name: "normal response", @@ -241,6 +242,7 @@ func TestResponseVariants(t *testing.T) { longitude: 0.0212, altitude: 83.93, }, + err: nil, }, { name: "llh empty array", @@ -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", @@ -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", @@ -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()) } }) } diff --git a/pkg/solver/loracloud/metrics.go b/pkg/solver/loracloud/metrics.go index c33192e..e6e21eb 100644 --- a/pkg/solver/loracloud/metrics.go +++ b/pkg/solver/loracloud/metrics.go @@ -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", @@ -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"}) )