From 24b2d6efc300afe0817ae28aa16d58d4f95287cf Mon Sep 17 00:00:00 2001 From: StefanSmile <148959525+StefanSmile@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:46:53 +0200 Subject: [PATCH] feat(tagxl): decode ts2 LTE and active GNSS settings on port 151 Add the ts2 settings replies to the shared port 151 layout: - 0xB1 LTE APN, 0xB2 LTE API key (only reported as configured, the key is never decoded) - 0xB3 LTE connection: upload time of day, chunk size, timeout, back-off, retry count - 0xD1 GPS config: scan duration, cool-off, HDOP/VDOP/PDOP thresholds - 0xD2 GPS constellation and power mode Devices that do not report these tags get the new fields as null. Tag 0x46 is unchanged (firmware hash). --- pkg/decoder/tagxl/v1/port151.go | 91 ++++++++++++++++ pkg/decoder/tagxl/v1/port151_lte_gnss_test.go | 101 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 pkg/decoder/tagxl/v1/port151_lte_gnss_test.go diff --git a/pkg/decoder/tagxl/v1/port151.go b/pkg/decoder/tagxl/v1/port151.go index acfa618..5521117 100644 --- a/pkg/decoder/tagxl/v1/port151.go +++ b/pkg/decoder/tagxl/v1/port151.go @@ -42,6 +42,28 @@ import ( // | | | 6: DR1-3 array (EU868 SF9-11, US915 SF7-9) | | // | | | 7: ADR (SF7-12) for EU868 | | // | | | See: https://docs.truvami.com/docs/Devices/tag%20XL%20/Payload%20Format%20%20tag%20XL/#settings-downlink +// | b1 | 0-32 | lte apn | ascii | +// | b2 | 0-32 | lte api key (only reported as configured) | ascii | +// | b3 | 9 | lte upload time of day (after 00:00 UTC) | uint16, min| +// | | | lte upload chunk size | uint16 | +// | | | lte connection timeout | uint16, s | +// | | | lte upload back-off period | uint16, min| +// | | | lte upload retry count | uint8 | +// | d1 | 10 | gps scan duration | uint16, s | +// | | | gps cool-off duration | uint16, s | +// | | | 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 | +// | | | 0: default (GPS+QZSS+SBAS+Galileo+BeiDou) | | +// | | | 1: GPS (+QZSS+SBAS) | | +// | | | 2: GPS+Galileo | | +// | | | 3: GPS+GLONASS | | +// | | | 4: GPS+BeiDou | | +// | | | 5: GPS+Galileo+BeiDou | | +// | | | 6: GPS+Galileo+GLONASS | | +// | | | 7: GPS+Galileo+GLONASS+BeiDou | | +// | | | gps power mode (0 = full power, 1 = low power) | uint8 | // +-----+------+------------------------------------------------+------------+ const ( @@ -77,6 +99,11 @@ const ( tlvTagResetCause = 0x4a tlvTagScanCounts = 0x4b tlvTagDataRate = 0x4e + tlvTagLteApn = 0xb1 + tlvTagLteApiKey = 0xb2 + tlvTagLteConnection = 0xb3 + tlvTagGpsConfig = 0xd1 + tlvTagGpsConstellation = 0xd2 ) type setterSpec struct { @@ -114,6 +141,20 @@ type Port151Payload struct { GnssScans *uint16 `json:"gnssScans"` WifiScans *uint16 `json:"wifiScans"` DataRate *decoder.DataRate `json:"dataRate"` + LteApn *string `json:"lteApn"` + LteApiKeyConfigured *bool `json:"lteApiKeyConfigured"` + LteUploadTimeOfDay *uint16 `json:"lteUploadTimeOfDay"` + LteUploadChunkSize *uint16 `json:"lteUploadChunkSize"` + LteConnectionTimeout *uint16 `json:"lteConnectionTimeout"` + LteUploadBackoff *uint16 `json:"lteUploadBackoff"` + LteUploadRetryCount *uint8 `json:"lteUploadRetryCount"` + GpsScanDuration *uint16 `json:"gpsScanDuration"` + GpsCooloffDuration *uint16 `json:"gpsCooloffDuration"` + GpsHdopThreshold *float32 `json:"gpsHdopThreshold"` + GpsVdopThreshold *float32 `json:"gpsVdopThreshold"` + GpsPdopThreshold *float32 `json:"gpsPdopThreshold"` + GpsConstellation *uint8 `json:"gpsConstellation"` + GpsPowerMode *uint8 `json:"gpsPowerMode"` } var _ decoder.UplinkFeatureBattery = &Port151Payload{} @@ -321,12 +362,62 @@ func port151PayloadConfig() common.PayloadConfig { } return nil }}, + {Name: "LteApn", Tag: tlvTagLteApn, Optional: true, Feature: decoder.FeatureConfig}, + // never expose the api key itself, only whether one is configured + {Name: "LteApiKeyConfigured", Tag: tlvTagLteApiKey, Optional: true, Feature: decoder.FeatureConfig, Transform: func(v any) any { + return len(v.([]byte)) > 0 + }}, + {Name: "LteUploadTimeOfDay", Tag: tlvTagLteConnection, Optional: true, Feature: decoder.FeatureConfig, Transform: uint16At(0)}, + {Name: "LteUploadChunkSize", Tag: tlvTagLteConnection, Optional: true, Feature: decoder.FeatureConfig, Transform: uint16At(2)}, + {Name: "LteConnectionTimeout", Tag: tlvTagLteConnection, Optional: true, Feature: decoder.FeatureConfig, Transform: uint16At(4)}, + {Name: "LteUploadBackoff", Tag: tlvTagLteConnection, Optional: true, Feature: decoder.FeatureConfig, Transform: uint16At(6)}, + {Name: "LteUploadRetryCount", Tag: tlvTagLteConnection, Optional: true, Feature: decoder.FeatureConfig, Transform: uint8At(8)}, + {Name: "GpsScanDuration", Tag: tlvTagGpsConfig, Optional: true, Feature: decoder.FeatureConfig, Transform: uint16At(0)}, + {Name: "GpsCooloffDuration", Tag: tlvTagGpsConfig, Optional: true, Feature: decoder.FeatureConfig, Transform: uint16At(2)}, + {Name: "GpsHdopThreshold", Tag: tlvTagGpsConfig, Optional: true, Feature: decoder.FeatureConfig, Transform: dopAt(4)}, + {Name: "GpsVdopThreshold", Tag: tlvTagGpsConfig, Optional: true, Feature: decoder.FeatureConfig, Transform: dopAt(6)}, + {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)}, }, TargetType: reflect.TypeOf(Port151Payload{}), Features: []decoder.Feature{decoder.FeatureDataRate}, } } +// uint16At returns the big-endian uint16 at offset, or nil when the value is too short. +func uint16At(offset int) func(v any) any { + return func(v any) any { + b := v.([]byte) + if len(b) < offset+2 { + return nil + } + return common.BytesToUint16(b[offset : offset+2]) + } +} + +// uint8At returns the byte at offset, or nil when the value is too short. +func uint8At(offset int) func(v any) any { + return func(v any) any { + b := v.([]byte) + if len(b) < offset+1 { + return nil + } + return b[offset] + } +} + +// dopAt returns the DOP threshold (transmitted as DOP x10) at offset. +func dopAt(offset int) func(v any) any { + return func(v any) any { + value := uint16At(offset)(v) + if value == nil { + return nil + } + return float32(value.(uint16)) / 10 + } +} + // DataRateFromUint8 converts a uint8 data rate value to the corresponding TagXL DataRate enum. func DataRateFromUint8(value uint8) decoder.DataRate { switch value { diff --git a/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go b/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go new file mode 100644 index 0000000..ea66ebd --- /dev/null +++ b/pkg/decoder/tagxl/v1/port151_lte_gnss_test.go @@ -0,0 +1,101 @@ +package tagxl + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "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/decoder" + "github.com/truvami/decoder/pkg/solver" +) + +func decodePort151(t *testing.T, payload string) (*decoder.DecodedUplink, Port151Payload) { + t.Helper() + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, logger.Logger) + decoded, err := dec.Decode(context.TODO(), payload, 151) + require.NoError(t, err) + data, ok := decoded.Data.(Port151Payload) + require.True(t, ok, "unexpected payload type %T", decoded.Data) + return decoded, data +} + +func TestPort151_GpsSettings(t *testing.T) { + t.Parallel() + + // settings report after a C2 + C1 downlink: GPS+Galileo, low power; scan 120 s, cool-off 10 s, PDOP <= 3.0 + decoded, data := decodePort151(t, "4c1002d2020201d10a0078000a00000000001e") + + assert.True(t, decoded.Is(decoder.FeatureConfig)) + assert.Equal(t, common.Uint8Ptr(2), data.GpsConstellation) + assert.Equal(t, common.Uint8Ptr(1), data.GpsPowerMode) + assert.Equal(t, common.Uint16Ptr(120), data.GpsScanDuration) + assert.Equal(t, common.Uint16Ptr(10), data.GpsCooloffDuration) + assert.Equal(t, common.Float32Ptr(0), data.GpsHdopThreshold) + assert.Equal(t, common.Float32Ptr(0), data.GpsVdopThreshold) + assert.Equal(t, common.Float32Ptr(3), data.GpsPdopThreshold) +} + +func TestPort151_LteSettings(t *testing.T) { + t.Parallel() + + // APN "iot.1nce.net", API key "abc", upload 02:00 UTC, 100 records, 180 s timeout, 30 min back-off, 3 retries + decoded, data := decodePort151(t, "4c2203b10c696f742e316e63652e6e6574b203616263b3090078006400b4001e03") + + assert.True(t, decoded.Is(decoder.FeatureConfig)) + assert.Equal(t, common.StringPtr("iot.1nce.net"), data.LteApn) + assert.Equal(t, common.BoolPtr(true), data.LteApiKeyConfigured) + assert.Equal(t, common.Uint16Ptr(120), data.LteUploadTimeOfDay) + assert.Equal(t, common.Uint16Ptr(100), data.LteUploadChunkSize) + assert.Equal(t, common.Uint16Ptr(180), data.LteConnectionTimeout) + assert.Equal(t, common.Uint16Ptr(30), data.LteUploadBackoff) + assert.Equal(t, common.Uint8Ptr(3), data.LteUploadRetryCount) + + // the api key itself must never be exposed + encoded, err := json.Marshal(data) + require.NoError(t, err) + assert.False(t, strings.Contains(string(encoded), "abc")) +} + +func TestPort151_EmptyLteApiKey(t *testing.T) { + t.Parallel() + + _, data := decodePort151(t, "4c0301b200") + assert.Equal(t, common.BoolPtr(false), data.LteApiKeyConfigured) +} + +func TestPort151_TruncatedLteGpsValues(t *testing.T) { + t.Parallel() + + // truncated 0xD1 and 0xB3 only fill the fields that are fully present + _, data := decodePort151(t, "4c0802d1020078b30300780a") + + assert.Equal(t, common.Uint16Ptr(120), data.GpsScanDuration) + assert.Nil(t, data.GpsCooloffDuration) + assert.Nil(t, data.GpsPdopThreshold) + assert.Equal(t, common.Uint16Ptr(120), data.LteUploadTimeOfDay) + assert.Nil(t, data.LteUploadChunkSize) + assert.Nil(t, data.LteUploadRetryCount) +} + +func TestPort151_LteGpsNullWhenNotReported(t *testing.T) { + t.Parallel() + + // a Tag XL report without the new tags: fields stay nil and are encoded as null, + // and 0x46 is still the firmware hash + _, data := decodePort151(t, "4c0d0245020a924604aabbccdd") + + assert.Equal(t, common.StringPtr("aabbccdd"), data.FirmwareHash) + assert.Nil(t, data.GetFirmwareVersion()) + assert.Nil(t, data.LteApn) + assert.Nil(t, data.GpsConstellation) + + encoded, err := json.Marshal(data) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"gpsConstellation":null`) + assert.Contains(t, string(encoded), `"lteApn":null`) +}