From c3b85290a8de1b8a37a93e3e1c1308405dd028e8 Mon Sep 17 00:00:00 2001 From: Stefan Smiljanic Date: Fri, 29 May 2026 13:51:08 +0000 Subject: [PATCH 1/2] Add Smart Label port 10 active GNSS uplink decoder. Decode lorawan_gps_ul_ts_t (20-byte fix with status, lat/lon, altitude, timestamp, battery, TTF, PDOP, and satellites) and register GNSS/timestamp/battery features. Includes Zurich sample regression test. Co-authored-by: Cursor --- .secrets.baseline | 56 ++++++++--- pkg/decoder/smartlabel/v1/decoder.go | 45 +++++++++ pkg/decoder/smartlabel/v1/decoder_test.go | 95 ++++++++++++++++++ pkg/decoder/smartlabel/v1/port10.go | 111 ++++++++++++++++++++++ 4 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 pkg/decoder/smartlabel/v1/port10.go diff --git a/.secrets.baseline b/.secrets.baseline index 42f6749..c5b20a3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -309,7 +309,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "2969e12a864a2091be4082d99c1767a6d225ef9f", "is_verified": false, - "line_number": 170, + "line_number": 171, "is_secret": false }, { @@ -317,7 +317,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "18911f680f30a3d441a7314a5f4131ccdf5a291d", "is_verified": false, - "line_number": 193, + "line_number": 194, "is_secret": false }, { @@ -325,15 +325,43 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "03799cbbbde9a26987fc61ee9d1c7607efa5b6c3", "is_verified": false, - "line_number": 216, + "line_number": 217, "is_secret": false }, + { + "type": "Hex High Entropy String", + "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", + "hashed_secret": "5834801389e8dc5649da5afb03daa5984c109656", + "is_verified": false, + "line_number": 241 + }, + { + "type": "Hex High Entropy String", + "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", + "hashed_secret": "728a3e88a062cfba092183aca6fe9b07841fd501", + "is_verified": false, + "line_number": 256 + }, + { + "type": "Hex High Entropy String", + "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", + "hashed_secret": "b5950857a05765aaec2390c6fb6e6d178fde38ec", + "is_verified": false, + "line_number": 271 + }, + { + "type": "Hex High Entropy String", + "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", + "hashed_secret": "88d19da2a0e9309b61e89836d80f63274d9faeea", + "is_verified": false, + "line_number": 286 + }, { "type": "Hex High Entropy String", "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "d85e5644e91feb126f202af26abefd4aadde571e", "is_verified": false, - "line_number": 239, + "line_number": 307, "is_secret": false }, { @@ -341,7 +369,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "9db4104f44fd40f26bce7072ad15bc10f1e833d6", "is_verified": false, - "line_number": 259, + "line_number": 327, "is_secret": false }, { @@ -349,7 +377,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "517accb9519da7da9ae0f3ee9eac83543169ee17", "is_verified": false, - "line_number": 269, + "line_number": 337, "is_secret": false }, { @@ -357,7 +385,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "32179884041e9ddc27e1c5e0e45ccc6e81637d65", "is_verified": false, - "line_number": 279, + "line_number": 347, "is_secret": false }, { @@ -365,7 +393,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "07cb4aff970c7271688f1e8eaf214a516d3970e3", "is_verified": false, - "line_number": 299, + "line_number": 367, "is_secret": false }, { @@ -373,7 +401,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "bc4afa2f3630480ca72a32c5c8421b39f3ce1a71", "is_verified": false, - "line_number": 332, + "line_number": 400, "is_secret": false }, { @@ -381,7 +409,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "395900609a0d94e6b75d0f0cb6b647d1d554c442", "is_verified": false, - "line_number": 343, + "line_number": 411, "is_secret": false }, { @@ -389,7 +417,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "81de30f2e09afabf90f74a4addd1ddbc354277d4", "is_verified": false, - "line_number": 356, + "line_number": 424, "is_secret": false }, { @@ -397,7 +425,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "a158f37d59e2f1ea505e92294149581814a5ffe3", "is_verified": false, - "line_number": 373, + "line_number": 441, "is_secret": false }, { @@ -405,7 +433,7 @@ "filename": "pkg/decoder/smartlabel/v1/decoder_test.go", "hashed_secret": "884f72cf02528dcd37a031e2af8575273d4394e2", "is_verified": false, - "line_number": 529, + "line_number": 619, "is_secret": false } ], @@ -2235,5 +2263,5 @@ } ] }, - "generated_at": "2026-04-28T18:24:36Z" + "generated_at": "2026-05-29T14:09:23Z" } diff --git a/pkg/decoder/smartlabel/v1/decoder.go b/pkg/decoder/smartlabel/v1/decoder.go index ba38dee..97c882a 100644 --- a/pkg/decoder/smartlabel/v1/decoder.go +++ b/pkg/decoder/smartlabel/v1/decoder.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "reflect" + "time" "github.com/truvami/decoder/pkg/common" "github.com/truvami/decoder/pkg/decoder" @@ -104,6 +105,22 @@ func (t SmartLabelv1Decoder) getConfig(port uint8, data string) (common.PayloadC TargetType: reflect.TypeOf(Port4Payload{}), Features: []decoder.Feature{decoder.FeatureConfig, decoder.FeatureFirmwareVersion}, }, nil + case 10: + return common.PayloadConfig{ + Fields: []common.FieldConfig{ + {Name: "Status", Start: 0, Length: 1}, + {Name: "Latitude", Start: 1, Length: 4, Transform: latitude}, + {Name: "Longitude", Start: 5, Length: 4, Transform: longitude}, + {Name: "Altitude", Start: 9, Length: 2, Transform: altitude}, + {Name: "Timestamp", Start: 11, Length: 4, Transform: timestamp}, + {Name: "Battery", Start: 15, Length: 2, Transform: gnssBattery}, + {Name: "TTF", Start: 17, Length: 1, Transform: ttf}, + {Name: "PDOP", Start: 18, Length: 1, Transform: pdop}, + {Name: "Satellites", Start: 19, Length: 1}, + }, + TargetType: reflect.TypeOf(Port10Payload{}), + Features: []decoder.Feature{decoder.FeatureGNSS, decoder.FeatureTimestamp, decoder.FeatureBattery}, + }, nil case 11: return common.PayloadConfig{ Fields: []common.FieldConfig{ @@ -214,3 +231,31 @@ func temperature(v any) any { func humidity(v any) any { return float32(common.BytesToUint8(v.([]byte))) / 2 } + +func latitude(v any) any { + return float64(common.BytesToInt32(v.([]byte))) / 1000000 +} + +func longitude(v any) any { + return float64(common.BytesToInt32(v.([]byte))) / 1000000 +} + +func altitude(v any) any { + return float64(common.BytesToUint16(v.([]byte))) / 10 +} + +func timestamp(v any) any { + return time.Unix(int64(common.BytesToUint32(v.([]byte))), 0).UTC() +} + +func gnssBattery(v any) any { + return float64(common.BytesToUint16(v.([]byte))) / 1000 +} + +func ttf(v any) any { + return time.Duration(int64(common.BytesToUint8(v.([]byte)))) * time.Second +} + +func pdop(v any) any { + return float64(common.BytesToUint8(v.([]byte))) / 2 +} diff --git a/pkg/decoder/smartlabel/v1/decoder_test.go b/pkg/decoder/smartlabel/v1/decoder_test.go index 0e8d396..bb59b40 100644 --- a/pkg/decoder/smartlabel/v1/decoder_test.go +++ b/pkg/decoder/smartlabel/v1/decoder_test.go @@ -12,6 +12,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/stretchr/testify/assert" helpers "github.com/truvami/decoder/pkg/common" @@ -235,6 +236,73 @@ func TestDecode(t *testing.T) { FirmwareVersionPatch: 12, }, }, + { + // Active GNSS fix near Zurich (tracker sample) + payload: "0002d2eeb40081d77ca3706a196afd0e74000009", + port: 10, + expected: Port10Payload{ + Status: 0, + Latitude: 47.3781, + Longitude: 8.509308, + Altitude: 4184, + Timestamp: time.Date(2026, 5, 29, 10, 31, 25, 0, time.UTC), + Battery: 3.7, + TTF: helpers.DurationPtr(0), + PDOP: helpers.Float64Ptr(0), + Satellites: helpers.Uint8Ptr(9), + }, + }, + { + payload: "0002d308b50082457f16eb66c4a5cd0ed3000505", + port: 10, + expected: Port10Payload{ + Status: 0, + Latitude: 47.384757, + Longitude: 8.537471, + Altitude: 586.7, + Timestamp: time.Date(2024, 8, 20, 14, 18, 53, 0, time.UTC), + Battery: 3.795, + TTF: helpers.DurationPtr(0), + PDOP: helpers.Float64Ptr(2.5), + Satellites: helpers.Uint8Ptr(5), + }, + }, + { + payload: "0002d30b070082491f11256718d9fe0ede190505", + port: 10, + expected: Port10Payload{ + Status: 0, + Latitude: 47.385351, + Longitude: 8.538399, + Altitude: 438.9, + Timestamp: time.Date(2024, 10, 23, 11, 11, 58, 0, time.UTC), + Battery: 3.806, + PDOP: helpers.Float64Ptr(2.5), + Satellites: helpers.Uint8Ptr(5), + TTF: helpers.DurationPtr(25 * time.Second), + }, + }, + { + payload: "0002d30b070082491f11256718d9fe0e74190505", + port: 10, + expected: Port10Payload{ + Status: 0, + Latitude: 47.385351, + Longitude: 8.538399, + Altitude: 438.9, + Timestamp: time.Date(2024, 10, 23, 11, 11, 58, 0, time.UTC), + Battery: 3.7, + PDOP: helpers.Float64Ptr(2.5), + Satellites: helpers.Uint8Ptr(5), + TTF: helpers.DurationPtr(25 * time.Second), + }, + }, + { + payload: "00deadbeef", + port: 10, + expected: nil, + expectedErr: "invalid payload length", + }, { payload: "0ca90dbd07fa69", port: 11, @@ -445,6 +513,24 @@ func TestDecodeWithNoopSolver(t *testing.T) { } } +func TestPort10Features(t *testing.T) { + d := NewSmartLabelv1Decoder(context.TODO(), solver.MockSolverV1{}, zap.NewNop()) + decoded, err := d.Decode(context.TODO(), "0002d30b070082491f11256718d9fe0ede190505", 10) + assert.NoError(t, err) + assert.NotNil(t, decoded) + + assert.True(t, decoded.Is(decoder.FeatureGNSS)) + assert.True(t, decoded.Is(decoder.FeatureTimestamp)) + assert.True(t, decoded.Is(decoder.FeatureBattery)) + assert.False(t, decoded.Is(decoder.FeatureMoving)) + assert.False(t, decoded.Is(decoder.FeatureDutyCycle)) + assert.False(t, decoded.Is(decoder.FeatureConfigChange)) + + payload, ok := decoded.Data.(Port10Payload) + assert.True(t, ok) + assert.Equal(t, uint8(0), payload.Status) +} + func TestInvalidPort(t *testing.T) { logger := zap.NewExample() defer func() { @@ -518,6 +604,10 @@ func TestFeatures(t *testing.T) { payload: "fdb7218f6c166fadb359ea3bdec77daff72faac81784ab263386a455d3a73592a063900ba262b95a6ffc86", port: 197, }, + { + payload: "0002d30b070082491f11256718d9fe0ede190505", + port: 10, + }, } mux := http.NewServeMux() @@ -708,6 +798,11 @@ func TestMarshal(t *testing.T) { port: 4, expected: []string{"\"dataRate\": \"automatic-wide\"", "\"gnss\": true", "\"temperatureUpperThreshold\": 40", "\"temperatureLowerThreshold\": -20"}, }, + { + payload: "0002d30b070082491f11256718d9fe0ede190505", + port: 10, + expected: []string{"\"status\": 0", "\"latitude\": 47.385351", "\"battery\": \"3.806v\"", "\"satellites\": 5"}, + }, { payload: "0f50107904da8d", port: 11, diff --git a/pkg/decoder/smartlabel/v1/port10.go b/pkg/decoder/smartlabel/v1/port10.go new file mode 100644 index 0000000..ae8f66f --- /dev/null +++ b/pkg/decoder/smartlabel/v1/port10.go @@ -0,0 +1,111 @@ +package smartlabel + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/truvami/decoder/pkg/common" + "github.com/truvami/decoder/pkg/decoder" +) + +// Active GNSS uplink (lorawan_gps_ul_ts_t). +// +// +------+------+-------------------------------------------+------------------------+ +// | Byte | Size | Description | Format | +// +------+------+-------------------------------------------+------------------------+ +// | 0 | 1 | Status | uint8 (FW always 0) | +// | 1 | 4 | Latitude | int32, 1/1'000'000 deg | +// | 5 | 4 | Longitude | int32, 1/1'000'000 deg | +// | 9 | 2 | Altitude | uint16, 1/10 meter | +// | 11 | 4 | Unix timestamp | uint32 | +// | 15 | 2 | voltage_temp (battery) | uint16, mV | +// | 17 | 1 | Time to fix | uint8, s | +// | 18 | 1 | Position dilution of precision | uint8, 1/2 meter | +// | 19 | 1 | Number of satellites | uint8 | +// +------+------+-------------------------------------------+------------------------+ + +type Port10Payload struct { + Status uint8 `json:"status"` + Latitude float64 `json:"latitude" validate:"gte=-90,lte=90"` + Longitude float64 `json:"longitude" validate:"gte=-180,lte=180"` + Altitude float64 `json:"altitude"` + Timestamp time.Time `json:"timestamp"` + Battery float64 `json:"battery" validate:"gte=1,lte=5"` + TTF *time.Duration `json:"ttf"` + PDOP *float64 `json:"pdop"` + Satellites *uint8 `json:"satellites" validate:"gte=3,lte=27"` +} + +func (p Port10Payload) MarshalJSON() ([]byte, error) { + type Alias Port10Payload + var ttf *string + if p.TTF != nil { + ttf = common.StringPtr(p.TTF.String()) + } + var pdop *string + if p.PDOP != nil { + pdop = common.StringPtr(fmt.Sprintf("%.1fm", *p.PDOP)) + } + return json.Marshal(&struct { + *Alias + Altitude string `json:"altitude"` + Timestamp string `json:"timestamp"` + Battery string `json:"battery"` + TTF *string `json:"ttf"` + PDOP *string `json:"pdop"` + Satellites *uint8 `json:"satellites"` + }{ + Alias: (*Alias)(&p), + Altitude: fmt.Sprintf("%.1fm", p.Altitude), + Timestamp: p.Timestamp.Format(time.RFC3339), + Battery: fmt.Sprintf("%.3fv", p.Battery), + TTF: ttf, + PDOP: pdop, + Satellites: p.Satellites, + }) +} + +var _ decoder.UplinkFeatureTimestamp = &Port10Payload{} +var _ decoder.UplinkFeatureGNSS = &Port10Payload{} +var _ decoder.UplinkFeatureBattery = &Port10Payload{} + +func (p Port10Payload) GetTimestamp() *time.Time { + return &p.Timestamp +} + +func (p Port10Payload) GetLatitude() float64 { + return p.Latitude +} + +func (p Port10Payload) GetLongitude() float64 { + return p.Longitude +} + +func (p Port10Payload) GetAltitude() float64 { + return p.Altitude +} + +func (p Port10Payload) GetAccuracy() *float64 { + return nil +} + +func (p Port10Payload) GetTTF() *time.Duration { + return p.TTF +} + +func (p Port10Payload) GetPDOP() *float64 { + return p.PDOP +} + +func (p Port10Payload) GetSatellites() *uint8 { + return p.Satellites +} + +func (p Port10Payload) GetBatteryVoltage() float64 { + return p.Battery +} + +func (p Port10Payload) GetLowBattery() *bool { + return nil +} From 3c5e1c796920d33f7e98f1424fe6b38ff957fea3 Mon Sep 17 00:00:00 2001 From: Stefan Smiljanic Date: Fri, 29 May 2026 14:20:10 +0000 Subject: [PATCH 2/2] Fix Smart Label port 10 altitude scaling. Use centimeters on the wire (divide by 100 for meters) instead of the Tag SL 1/10 meter convention. Zurich sample now decodes to ~418 m. Co-authored-by: Cursor --- pkg/decoder/smartlabel/v1/decoder.go | 6 +++--- pkg/decoder/smartlabel/v1/decoder_test.go | 8 ++++---- pkg/decoder/smartlabel/v1/port10.go | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/decoder/smartlabel/v1/decoder.go b/pkg/decoder/smartlabel/v1/decoder.go index 97c882a..678ebe4 100644 --- a/pkg/decoder/smartlabel/v1/decoder.go +++ b/pkg/decoder/smartlabel/v1/decoder.go @@ -111,7 +111,7 @@ func (t SmartLabelv1Decoder) getConfig(port uint8, data string) (common.PayloadC {Name: "Status", Start: 0, Length: 1}, {Name: "Latitude", Start: 1, Length: 4, Transform: latitude}, {Name: "Longitude", Start: 5, Length: 4, Transform: longitude}, - {Name: "Altitude", Start: 9, Length: 2, Transform: altitude}, + {Name: "Altitude", Start: 9, Length: 2, Transform: port10Altitude}, {Name: "Timestamp", Start: 11, Length: 4, Transform: timestamp}, {Name: "Battery", Start: 15, Length: 2, Transform: gnssBattery}, {Name: "TTF", Start: 17, Length: 1, Transform: ttf}, @@ -240,8 +240,8 @@ func longitude(v any) any { return float64(common.BytesToInt32(v.([]byte))) / 1000000 } -func altitude(v any) any { - return float64(common.BytesToUint16(v.([]byte))) / 10 +func port10Altitude(v any) any { + return float64(common.BytesToUint16(v.([]byte))) / 100 } func timestamp(v any) any { diff --git a/pkg/decoder/smartlabel/v1/decoder_test.go b/pkg/decoder/smartlabel/v1/decoder_test.go index bb59b40..743ad3f 100644 --- a/pkg/decoder/smartlabel/v1/decoder_test.go +++ b/pkg/decoder/smartlabel/v1/decoder_test.go @@ -244,7 +244,7 @@ func TestDecode(t *testing.T) { Status: 0, Latitude: 47.3781, Longitude: 8.509308, - Altitude: 4184, + Altitude: 418.4, Timestamp: time.Date(2026, 5, 29, 10, 31, 25, 0, time.UTC), Battery: 3.7, TTF: helpers.DurationPtr(0), @@ -259,7 +259,7 @@ func TestDecode(t *testing.T) { Status: 0, Latitude: 47.384757, Longitude: 8.537471, - Altitude: 586.7, + Altitude: 58.67, Timestamp: time.Date(2024, 8, 20, 14, 18, 53, 0, time.UTC), Battery: 3.795, TTF: helpers.DurationPtr(0), @@ -274,7 +274,7 @@ func TestDecode(t *testing.T) { Status: 0, Latitude: 47.385351, Longitude: 8.538399, - Altitude: 438.9, + Altitude: 43.89, Timestamp: time.Date(2024, 10, 23, 11, 11, 58, 0, time.UTC), Battery: 3.806, PDOP: helpers.Float64Ptr(2.5), @@ -289,7 +289,7 @@ func TestDecode(t *testing.T) { Status: 0, Latitude: 47.385351, Longitude: 8.538399, - Altitude: 438.9, + Altitude: 43.89, Timestamp: time.Date(2024, 10, 23, 11, 11, 58, 0, time.UTC), Battery: 3.7, PDOP: helpers.Float64Ptr(2.5), diff --git a/pkg/decoder/smartlabel/v1/port10.go b/pkg/decoder/smartlabel/v1/port10.go index ae8f66f..cb75ff2 100644 --- a/pkg/decoder/smartlabel/v1/port10.go +++ b/pkg/decoder/smartlabel/v1/port10.go @@ -17,7 +17,7 @@ import ( // | 0 | 1 | Status | uint8 (FW always 0) | // | 1 | 4 | Latitude | int32, 1/1'000'000 deg | // | 5 | 4 | Longitude | int32, 1/1'000'000 deg | -// | 9 | 2 | Altitude | uint16, 1/10 meter | +// | 9 | 2 | Altitude | uint16, centimeters | // | 11 | 4 | Unix timestamp | uint32 | // | 15 | 2 | voltage_temp (battery) | uint16, mV | // | 17 | 1 | Time to fix | uint8, s |