From e7d2358f3991e28f36a41724468ff18b84700b7b Mon Sep 17 00:00:00 2001 From: StefanSmile <148959525+StefanSmile@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:08:41 +0200 Subject: [PATCH 1/3] feat(tagxl): decode GPS dynamic model and static hold on port 151 ts2 firmware extends the 0xD2 GPS receiver reply from 2 to 5 bytes: - gpsDynamicModel: u-blox CFG-NAVSPG-DYNMODEL (0 portable, 3 pedestrian, 4 automotive, ...) - gpsStaticHoldSpeed: CFG-MOT speed threshold in cm/s (0 = off) - gpsStaticHoldDistance: CFG-MOT exit distance in m The 2-byte reply from older firmware still decodes; the new fields stay null. --- pkg/decoder/tagxl/v1/port151.go | 15 ++++++++++++++- pkg/decoder/tagxl/v1/port151_lte_gnss_test.go | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pkg/decoder/tagxl/v1/port151.go b/pkg/decoder/tagxl/v1/port151.go index 5521117..0c2fb04 100644 --- a/pkg/decoder/tagxl/v1/port151.go +++ b/pkg/decoder/tagxl/v1/port151.go @@ -54,7 +54,7 @@ import ( // | | | gps hdop threshold (0 = disabled) | uint16, x10| // | | | gps vdop threshold (0 = disabled) | uint16, x10| // | | | gps pdop threshold (0 = disabled) | uint16, x10| -// | d2 | 2 | gps constellation | uint8 | +// | d2 | 2-5 | gps constellation | uint8 | // | | | 0: default (GPS+QZSS+SBAS+Galileo+BeiDou) | | // | | | 1: GPS (+QZSS+SBAS) | | // | | | 2: GPS+Galileo | | @@ -64,6 +64,13 @@ import ( // | | | 6: GPS+Galileo+GLONASS | | // | | | 7: GPS+Galileo+GLONASS+BeiDou | | // | | | gps power mode (0 = full power, 1 = low power) | uint8 | +// | | | gps dynamic model (u-blox CFG-NAVSPG-DYNMODEL) | uint8 | +// | | | 0: portable, 2: stationary, 3: pedestrian, | | +// | | | 4: automotive, 5: sea, 6-8: airborne <1/2/4g,| | +// | | | 9: wrist, 10: bike | | +// | | | gps static hold speed (0 = off) | uint8, cm/s| +// | | | gps static hold exit distance | uint8, m | +// | | | (older ts2 firmware sends only the first 2) | | // +-----+------+------------------------------------------------+------------+ const ( @@ -155,6 +162,9 @@ type Port151Payload struct { GpsPdopThreshold *float32 `json:"gpsPdopThreshold"` GpsConstellation *uint8 `json:"gpsConstellation"` GpsPowerMode *uint8 `json:"gpsPowerMode"` + GpsDynamicModel *uint8 `json:"gpsDynamicModel"` + GpsStaticHoldSpeed *uint8 `json:"gpsStaticHoldSpeed"` + GpsStaticHoldDistance *uint8 `json:"gpsStaticHoldDistance"` } var _ decoder.UplinkFeatureBattery = &Port151Payload{} @@ -379,6 +389,9 @@ func port151PayloadConfig() common.PayloadConfig { {Name: "GpsPdopThreshold", Tag: tlvTagGpsConfig, Optional: true, Feature: decoder.FeatureConfig, Transform: dopAt(8)}, {Name: "GpsConstellation", Tag: tlvTagGpsConstellation, Optional: true, Feature: decoder.FeatureConfig, Transform: uint8At(0)}, {Name: "GpsPowerMode", Tag: tlvTagGpsConstellation, Optional: true, Feature: decoder.FeatureConfig, Transform: uint8At(1)}, + {Name: "GpsDynamicModel", Tag: tlvTagGpsConstellation, Optional: true, Feature: decoder.FeatureConfig, Transform: uint8At(2)}, + {Name: "GpsStaticHoldSpeed", Tag: tlvTagGpsConstellation, Optional: true, Feature: decoder.FeatureConfig, Transform: uint8At(3)}, + {Name: "GpsStaticHoldDistance", Tag: tlvTagGpsConstellation, Optional: true, Feature: decoder.FeatureConfig, Transform: uint8At(4)}, }, TargetType: reflect.TypeOf(Port151Payload{}), Features: []decoder.Feature{decoder.FeatureDataRate}, diff --git a/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go b/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go index ea66ebd..dfb9426 100644 --- a/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go +++ b/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go @@ -38,6 +38,25 @@ func TestPort151_GpsSettings(t *testing.T) { assert.Equal(t, common.Float32Ptr(0), data.GpsHdopThreshold) assert.Equal(t, common.Float32Ptr(0), data.GpsVdopThreshold) assert.Equal(t, common.Float32Ptr(3), data.GpsPdopThreshold) + + // 2-byte 0xD2 from older ts2 firmware: no dynamic model / static hold + assert.Nil(t, data.GpsDynamicModel) + assert.Nil(t, data.GpsStaticHoldSpeed) + assert.Nil(t, data.GpsStaticHoldDistance) +} + +func TestPort151_GpsReceiverConfig(t *testing.T) { + t.Parallel() + + // 5-byte 0xD2: all constellations, full power, automotive, static hold < 50 cm/s, exit after 10 m + decoded, data := decodePort151(t, "4c0701d205070004320a") + + assert.True(t, decoded.Is(decoder.FeatureConfig)) + assert.Equal(t, common.Uint8Ptr(7), data.GpsConstellation) + assert.Equal(t, common.Uint8Ptr(0), data.GpsPowerMode) + assert.Equal(t, common.Uint8Ptr(4), data.GpsDynamicModel) + assert.Equal(t, common.Uint8Ptr(50), data.GpsStaticHoldSpeed) + assert.Equal(t, common.Uint8Ptr(10), data.GpsStaticHoldDistance) } func TestPort151_LteSettings(t *testing.T) { From d8e2d2fb2d3ef8458d276e24fb82e6c096163e3f Mon Sep 17 00:00:00 2001 From: StefanSmile <148959525+StefanSmile@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:14:16 +0200 Subject: [PATCH 2/3] feat(tagxl): decode optional hAcc and speed on port 10 ts2 firmware appends two bytes to the active GNSS uplink: - byte 20: horizontal accuracy estimate (hAcc) in m, exposed via GetAccuracy - byte 21: speed over ground in km/h Both are optional: 20-byte uplinks from older firmware decode as before with accuracy and speed left nil. --- pkg/decoder/tagxl/v1/decoder.go | 7 ++++ pkg/decoder/tagxl/v1/port10.go | 19 +++++++++- pkg/decoder/tagxl/v1/port10_test.go | 56 +++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/pkg/decoder/tagxl/v1/decoder.go b/pkg/decoder/tagxl/v1/decoder.go index d854ae1..2ab741c 100644 --- a/pkg/decoder/tagxl/v1/decoder.go +++ b/pkg/decoder/tagxl/v1/decoder.go @@ -87,6 +87,13 @@ func (t TagXLv1Decoder) getConfig(port uint8, payload []byte) (common.PayloadCon {Name: "TTF", Start: 17, Length: 1, Transform: ttf}, {Name: "PDOP", Start: 18, Length: 1, Transform: pdop}, {Name: "Satellites", Start: 19, Length: 1}, + // optional: older firmware sends 20 bytes without hAcc and speed + {Name: "Accuracy", Start: 20, Length: 1, Optional: true, Transform: func(v any) any { + return float64(v.([]byte)[0]) + }}, + {Name: "Speed", Start: 21, Length: 1, Optional: true, Transform: func(v any) any { + return float64(v.([]byte)[0]) + }}, }, TargetType: reflect.TypeOf(Port10Payload{}), Features: []decoder.Feature{decoder.FeatureGNSS, decoder.FeatureTimestamp, decoder.FeatureBattery, decoder.FeatureMoving}, diff --git a/pkg/decoder/tagxl/v1/port10.go b/pkg/decoder/tagxl/v1/port10.go index 22d5743..7a2f380 100644 --- a/pkg/decoder/tagxl/v1/port10.go +++ b/pkg/decoder/tagxl/v1/port10.go @@ -23,7 +23,10 @@ import ( // | 17 | 1 | Time to fix | uint8, s | // | 18 | 1 | Position dilution of precision | uint8, 1/2 meter | // | 19 | 1 | Number of satellites | uint8 | +// | 20 | 1 | Horizontal accuracy estimate (hAcc) | uint8, m (optional) | +// | 21 | 1 | Speed over ground | uint8, km/h (optional) | // +------+------+-------------------------------------------+------------------------+ +// Bytes 20-21 were added later; 20-byte uplinks from older firmware leave them nil. type Port10Payload struct { Moving bool `json:"moving"` @@ -35,6 +38,8 @@ type Port10Payload struct { TTF *time.Duration `json:"ttf"` PDOP *float64 `json:"pdop"` Satellites *uint8 `json:"satellites" validate:"gte=3,lte=27"` + Accuracy *float64 `json:"accuracy"` + Speed *float64 `json:"speed"` } func (p Port10Payload) MarshalJSON() ([]byte, error) { @@ -47,6 +52,14 @@ func (p Port10Payload) MarshalJSON() ([]byte, error) { if p.PDOP != nil { pdop = common.StringPtr(fmt.Sprintf("%.1fm", *p.PDOP)) } + var accuracy *string + if p.Accuracy != nil { + accuracy = common.StringPtr(fmt.Sprintf("%.0fm", *p.Accuracy)) + } + var speed *string + if p.Speed != nil { + speed = common.StringPtr(fmt.Sprintf("%.0fkm/h", *p.Speed)) + } return json.Marshal(&struct { *Alias Altitude string `json:"altitude"` @@ -55,6 +68,8 @@ func (p Port10Payload) MarshalJSON() ([]byte, error) { TTF *string `json:"ttf"` PDOP *string `json:"pdop"` Satellites *uint8 `json:"satellites"` + Accuracy *string `json:"accuracy"` + Speed *string `json:"speed"` }{ Alias: (*Alias)(&p), Altitude: fmt.Sprintf("%.1fm", p.Altitude), @@ -63,6 +78,8 @@ func (p Port10Payload) MarshalJSON() ([]byte, error) { TTF: ttf, PDOP: pdop, Satellites: p.Satellites, + Accuracy: accuracy, + Speed: speed, }) } @@ -88,7 +105,7 @@ func (p Port10Payload) GetAltitude() float64 { } func (p Port10Payload) GetAccuracy() *float64 { - return nil + return p.Accuracy } func (p Port10Payload) GetTTF() *time.Duration { diff --git a/pkg/decoder/tagxl/v1/port10_test.go b/pkg/decoder/tagxl/v1/port10_test.go index c933cad..ed6fe61 100644 --- a/pkg/decoder/tagxl/v1/port10_test.go +++ b/pkg/decoder/tagxl/v1/port10_test.go @@ -1,10 +1,16 @@ package tagxl import ( + "context" + "encoding/json" "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/truvami/decoder/internal/logger" "github.com/truvami/decoder/pkg/common" + "github.com/truvami/decoder/pkg/solver" ) func TestPort10Payload_GNSSAndBatteryMethods(t *testing.T) { @@ -62,3 +68,53 @@ func TestPort10Payload_GNSSAndBatteryMethods(t *testing.T) { t.Fatalf("IsMoving() = %v, want true", got) } } + +func TestPort10_OptionalAccuracyAndSpeed(t *testing.T) { + t.Parallel() + + // 20-byte uplink from older firmware, then with hAcc (12 m) and speed over ground (34 km/h) appended + base := "0002d30b070082491f11256718d9fe0ede190505" + tests := []struct { + name string + payload string + accuracy *float64 + speed *float64 + }{ + {name: "20 bytes, older firmware", payload: base}, + {name: "21 bytes, hAcc only", payload: base + "0c", accuracy: common.Float64Ptr(12)}, + {name: "22 bytes, hAcc and speed", payload: base + "0c22", accuracy: common.Float64Ptr(12), speed: common.Float64Ptr(34)}, + } + + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, logger.Logger) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decoded, err := dec.Decode(context.TODO(), tt.payload, 10) + require.NoError(t, err) + data, ok := decoded.Data.(Port10Payload) + require.True(t, ok, "unexpected payload type %T", decoded.Data) + + // the existing fields decode the same regardless of length + assert.Equal(t, 47.385351, data.Latitude) + assert.Equal(t, common.Uint8Ptr(5), data.Satellites) + + assert.Equal(t, tt.accuracy, data.Accuracy) + assert.Equal(t, tt.accuracy, data.GetAccuracy()) + assert.Equal(t, tt.speed, data.Speed) + }) + } +} + +func TestPort10_AccuracyAndSpeedJSON(t *testing.T) { + t.Parallel() + + p := Port10Payload{Accuracy: common.Float64Ptr(12), Speed: common.Float64Ptr(34)} + encoded, err := json.Marshal(p) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"accuracy":"12m"`) + assert.Contains(t, string(encoded), `"speed":"34km/h"`) + + encoded, err = json.Marshal(Port10Payload{}) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"accuracy":null`) + assert.Contains(t, string(encoded), `"speed":null`) +} From 11dbf9577842659b8e1f525ec81d964b6f722c35 Mon Sep 17 00:00:00 2001 From: StefanSmile <148959525+StefanSmile@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:23:05 +0200 Subject: [PATCH 3/3] test(tagxl): cover port 10 and 0xD2 length edge cases - port 10: hAcc capped at 255 with speed 0, and a 23-byte uplink rejected as too long (documents the max-length check) - 0xD2: 3-byte reply (dynamic model only) and 6-byte reply (extra byte ignored) --- pkg/decoder/tagxl/v1/port10_test.go | 12 ++++++++++++ pkg/decoder/tagxl/v1/port151_lte_gnss_test.go | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/pkg/decoder/tagxl/v1/port10_test.go b/pkg/decoder/tagxl/v1/port10_test.go index ed6fe61..8da9590 100644 --- a/pkg/decoder/tagxl/v1/port10_test.go +++ b/pkg/decoder/tagxl/v1/port10_test.go @@ -83,6 +83,7 @@ func TestPort10_OptionalAccuracyAndSpeed(t *testing.T) { {name: "20 bytes, older firmware", payload: base}, {name: "21 bytes, hAcc only", payload: base + "0c", accuracy: common.Float64Ptr(12)}, {name: "22 bytes, hAcc and speed", payload: base + "0c22", accuracy: common.Float64Ptr(12), speed: common.Float64Ptr(34)}, + {name: "22 bytes, hAcc capped at 255, standing still", payload: base + "ff00", accuracy: common.Float64Ptr(255), speed: common.Float64Ptr(0)}, } dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, logger.Logger) @@ -118,3 +119,14 @@ func TestPort10_AccuracyAndSpeedJSON(t *testing.T) { assert.Contains(t, string(encoded), `"accuracy":null`) assert.Contains(t, string(encoded), `"speed":null`) } + +func TestPort10_RejectsUnknownTrailingBytes(t *testing.T) { + t.Parallel() + + // the maximum length is derived from the configured fields: a byte the decoder doesn't + // know yet is rejected, so the decoder must be updated before firmware appends more + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, logger.Logger) + _, err := dec.Decode(context.TODO(), "0002d30b070082491f11256718d9fe0ede1905050c22ff", 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "payload too long") +} diff --git a/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go b/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go index dfb9426..56d09f9 100644 --- a/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go +++ b/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go @@ -118,3 +118,22 @@ func TestPort151_LteGpsNullWhenNotReported(t *testing.T) { assert.Contains(t, string(encoded), `"gpsConstellation":null`) assert.Contains(t, string(encoded), `"lteApn":null`) } + +func TestPort151_GpsReceiverConfigPartialAndExtended(t *testing.T) { + t.Parallel() + + // 3-byte 0xD2: dynamic model only, static hold fields not reported + _, data := decodePort151(t, "4c0501d203020104") + assert.Equal(t, common.Uint8Ptr(2), data.GpsConstellation) + assert.Equal(t, common.Uint8Ptr(1), data.GpsPowerMode) + assert.Equal(t, common.Uint8Ptr(4), data.GpsDynamicModel) + assert.Nil(t, data.GpsStaticHoldSpeed) + assert.Nil(t, data.GpsStaticHoldDistance) + + // 6-byte 0xD2: a byte from future firmware is ignored, known fields still decode + _, data = decodePort151(t, "4c0801d206070004320aff") + assert.Equal(t, common.Uint8Ptr(7), data.GpsConstellation) + assert.Equal(t, common.Uint8Ptr(4), data.GpsDynamicModel) + assert.Equal(t, common.Uint8Ptr(50), data.GpsStaticHoldSpeed) + assert.Equal(t, common.Uint8Ptr(10), data.GpsStaticHoldDistance) +}