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
91 changes: 91 additions & 0 deletions pkg/decoder/tagxl/v1/port151.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -77,6 +99,11 @@ const (
tlvTagResetCause = 0x4a
tlvTagScanCounts = 0x4b
tlvTagDataRate = 0x4e
tlvTagLteApn = 0xb1
tlvTagLteApiKey = 0xb2
tlvTagLteConnection = 0xb3
tlvTagGpsConfig = 0xd1
tlvTagGpsConstellation = 0xd2
)

type setterSpec struct {
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down
101 changes: 101 additions & 0 deletions pkg/decoder/tagxl/v1/port151_lte_gnss_test.go
Original file line number Diff line number Diff line change
@@ -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`)
}
Loading