From 494c16cc61394a81de757924fcbea07cab2e4fbd Mon Sep 17 00:00:00 2001 From: Niko Kriznik Date: Mon, 14 Sep 2026 09:24:59 +0000 Subject: [PATCH 1/4] feat(tagxl): verify mixed getter and setter TLV dialects Campaigns can mix GETTER and SETTER commands. Compare only setter values, treat getters as presence checks, and build getter-only readback envelopes for Tag XL and Smart Label v2. --- pkg/decoder/tagxl/v1/configuration_dialect.go | 129 +++++++ pkg/decoder/tagxl/v1/configuration_match.go | 278 +++++++++------ .../tagxl/v1/configuration_match_test.go | 333 +++++++++++++++++- pkg/decoder/tagxl/v1/port151.go | 8 - 4 files changed, 625 insertions(+), 123 deletions(-) create mode 100644 pkg/decoder/tagxl/v1/configuration_dialect.go diff --git a/pkg/decoder/tagxl/v1/configuration_dialect.go b/pkg/decoder/tagxl/v1/configuration_dialect.go new file mode 100644 index 0000000..0d9dead --- /dev/null +++ b/pkg/decoder/tagxl/v1/configuration_dialect.go @@ -0,0 +1,129 @@ +package tagxl + +import "github.com/truvami/decoder/pkg/common" + +// ConfigurationDialect selects the fPort-151 command map for a device family. +type ConfigurationDialect int + +const ( + ConfigurationDialectUnspecified ConfigurationDialect = iota + ConfigurationDialectTagXL + ConfigurationDialectSmartLabelV2 +) + +func (d ConfigurationDialect) String() string { + switch d { + case ConfigurationDialectTagXL: + return "tagxl" + case ConfigurationDialectSmartLabelV2: + return "smartlabel-v2" + default: + return "unspecified" + } +} + +type commandSpec struct { + setterTag byte + getterTag byte + setterLen int + responseLen int + mask byte + prefixLen int + dataRate bool + validate func([]byte) error +} + +type dialectLimits struct { + maxCommands int + maxResponseBytes int +} + +type dialectSpec struct { + limits dialectLimits + setters map[byte]commandSpec + getters map[byte]commandSpec +} + +func newDialectSpec(limits dialectLimits, commands []commandSpec) dialectSpec { + spec := dialectSpec{ + limits: limits, + setters: make(map[byte]commandSpec, len(commands)), + getters: make(map[byte]commandSpec, len(commands)), + } + for _, command := range commands { + if command.setterTag != 0 { + spec.setters[command.setterTag] = command + } + if command.getterTag != 0 { + spec.getters[command.getterTag] = command + } + } + return spec +} + +func tagXLHeartbeatValidate(value []byte) error { + if len(value) == 1 && value[0] > 168 { + return common.ErrValidationFailed + } + return nil +} + +func tagXLAdvertisementValidate(value []byte) error { + if len(value) == 1 && value[0] == 0 { + return common.ErrValidationFailed + } + return nil +} + +var tagXLDialect = newDialectSpec(dialectLimits{maxCommands: 32, maxResponseBytes: 255}, []commandSpec{ + {setterTag: setterTagDeviceFlags, getterTag: tlvTagDeviceFlags, setterLen: 1, responseLen: 1, mask: 0x0f}, + {setterTag: setterTagMovingIntervals, getterTag: tlvTagMovingIntervals, setterLen: 4, responseLen: 4}, + {setterTag: setterTagAccelerationThreshold, getterTag: tlvTagAccelerationThreshold, setterLen: 4, responseLen: 4}, + {setterTag: setterTagHeartbeatInterval, getterTag: tlvTagHeartbeatInterval, setterLen: 1, responseLen: 1, validate: tagXLHeartbeatValidate}, + {setterTag: setterTagAdvertisementInterval, getterTag: tlvTagAdvertisementInterval, setterLen: 1, responseLen: 1, validate: tagXLAdvertisementValidate}, + {setterTag: setterTagRotationFlags, getterTag: tlvTagRotationFlags, setterLen: 1, responseLen: 1, mask: 0x03}, + {setterTag: 0x27, getterTag: 0x4d, setterLen: 2, responseLen: 2}, + {setterTag: setterTagDataRate, getterTag: tlvTagDataRate, setterLen: 1, responseLen: 1, dataRate: true}, + {setterTag: 0x29, getterTag: 0x4f, setterLen: 1, responseLen: 1}, + {setterTag: 0x2a, getterTag: 0x50, setterLen: 1, responseLen: 1, mask: 0x03}, + {setterTag: 0x2b, getterTag: 0x51, setterLen: 1, responseLen: 1, mask: 0x01}, + {getterTag: tlvTagBattery, responseLen: 2}, + {getterTag: tlvTagFirmwareHash, responseLen: 4}, + {getterTag: tlvTagResetCount, responseLen: 2}, + {getterTag: tlvTagResetCause, responseLen: 4}, + {getterTag: tlvTagScanCounts, responseLen: 4}, + {getterTag: 0x4c, responseLen: 4}, + {getterTag: 0x52, responseLen: 6}, +}) + +var smartLabelV2Dialect = newDialectSpec(dialectLimits{maxCommands: 30, maxResponseBytes: 51}, []commandSpec{ + {setterTag: 0x20, getterTag: 0x40, setterLen: 1, responseLen: 1, mask: 0x1f}, + {setterTag: 0x21, getterTag: 0x41, setterLen: 4, responseLen: 4}, + {setterTag: 0x22, getterTag: 0x42, setterLen: 4, responseLen: 4, prefixLen: 2}, + {setterTag: 0x23, getterTag: 0x43, setterLen: 1, responseLen: 1}, + {setterTag: 0x24, getterTag: 0x44, setterLen: 1, responseLen: 1}, + {setterTag: 0x28, getterTag: 0x4e, setterLen: 1, responseLen: 1, dataRate: true}, + {setterTag: 0x29, getterTag: 0x4c, setterLen: 2, responseLen: 2}, + {setterTag: 0x2a, getterTag: 0x4d, setterLen: 1, responseLen: 1}, + {setterTag: 0x2b, getterTag: 0x50, setterLen: 1, responseLen: 1}, + {setterTag: 0x2c, getterTag: 0x51, setterLen: 16, responseLen: 16}, + {setterTag: 0x30, getterTag: 0x60, setterLen: 7, responseLen: 7}, + {getterTag: 0x45, responseLen: 2}, + {getterTag: 0x46, responseLen: 4}, + {getterTag: 0x49, responseLen: 2}, + {getterTag: 0x4a, responseLen: 4}, + {getterTag: 0x4b, responseLen: 4}, + {getterTag: 0x61, responseLen: 1}, + {getterTag: 0x63, responseLen: 2}, +}) + +func dialectSpecFor(dialect ConfigurationDialect) (dialectSpec, error) { + switch dialect { + case ConfigurationDialectTagXL: + return tagXLDialect, nil + case ConfigurationDialectSmartLabelV2: + return smartLabelV2Dialect, nil + default: + return dialectSpec{}, errConfigurationUnknownDialect + } +} diff --git a/pkg/decoder/tagxl/v1/configuration_match.go b/pkg/decoder/tagxl/v1/configuration_match.go index 2d8d201..2fbc5aa 100644 --- a/pkg/decoder/tagxl/v1/configuration_match.go +++ b/pkg/decoder/tagxl/v1/configuration_match.go @@ -1,7 +1,7 @@ package tagxl import ( - "encoding/hex" + "bytes" "errors" "github.com/truvami/decoder/pkg/common" @@ -14,18 +14,20 @@ var ( errConfigurationUnsupportedCommand = errors.New("tag xl configuration: unsupported command") errConfigurationDuplicateTag = errors.New("tag xl configuration: duplicate comparable tag") errConfigurationInvalidDataRate = errors.New("tag xl configuration: invalid data rate") + errConfigurationTooLarge = errors.New("tag xl configuration: response exceeds dialect limit") + errConfigurationUnknownDialect = errors.New("tag xl configuration: unknown dialect") ) // ConfigurationComparison is the applicability-aware result of comparing a -// sent Tag XL settings downlink with a port-151 settings uplink. +// sent settings downlink with a port-151 settings uplink. type ConfigurationComparison int const ( - // ConfigurationIncomplete means the uplink omitted at least one requested setter. + // ConfigurationIncomplete means the uplink omitted at least one required tag. ConfigurationIncomplete ConfigurationComparison = iota - // ConfigurationMismatch means every requested setter was present and at least one differed. + // ConfigurationMismatch means every required tag was present and a setter differed. ConfigurationMismatch - // ConfigurationMatch means every requested setter was present and equal. + // ConfigurationMatch means every required tag was present and every setter matched. ConfigurationMatch ) @@ -42,18 +44,29 @@ func (c ConfigurationComparison) String() string { } } -// MatchConfiguration reports whether observed reflects every setter in sent. -// false with nil error means a well-formed non-match or incomplete report; -// non-nil error means malformed or unsupported. +type configurationRequirement struct { + spec commandSpec + wantValue []byte +} + +// MatchConfiguration reports whether observed reflects every setter in sent +// using the Tag XL dialect. func MatchConfiguration(sentHex, observedHex string) (bool, error) { result, err := CompareConfiguration(sentHex, observedHex) return result == ConfigurationMatch, err } -// CompareConfiguration classifies observed against every setter in sent. -// Incomplete reports are distinguished from complete contradictions so callers -// can ignore partial telemetry without treating it as a mismatch. +// CompareConfiguration classifies observed against the Tag XL dialect. func CompareConfiguration(sentHex, observedHex string) (ConfigurationComparison, error) { + return CompareConfigurationFor(ConfigurationDialectTagXL, sentHex, observedHex) +} + +// CompareConfigurationFor classifies observed against sent for one dialect. +func CompareConfigurationFor(dialect ConfigurationDialect, sentHex, observedHex string) (ConfigurationComparison, error) { + spec, err := dialectSpecFor(dialect) + if err != nil { + return 0, err + } sent, err := common.HexStringToBytes(sentHex) if err != nil { return 0, err @@ -63,166 +76,213 @@ func CompareConfiguration(sentHex, observedHex string) (ConfigurationComparison, return 0, err } - sentTLVHex, requestedSetters, err := parseConfigurationSent(sent) + requirements, err := parseConfigurationSent(spec, sent) if err != nil { return 0, err } - observedTLVHex, seen, err := parseConfigurationObserved(observed) + seen, err := parseConfigurationObserved(requirements, observed) if err != nil { return 0, err } - for _, setterTag := range requestedSetters { - spec, ok := setterSpecs[setterTag] - if !ok { - return 0, errConfigurationUnsupportedCommand - } - if _, ok := seen[spec.tlvTag]; !ok { + for tag := range requirements { + if _, ok := seen[tag]; !ok { return ConfigurationIncomplete, nil } } + for tag, requirement := range requirements { + if requirement.wantValue == nil { + continue + } + for _, value := range seen[tag] { + if !compareRequirementValue(requirement, value) { + return ConfigurationMismatch, nil + } + } + } + return ConfigurationMatch, nil +} - sentPayload, err := decodePort151Payload(sentTLVHex) +// ValidateConfiguration reports whether sent is a verifiable payload for dialect. +func ValidateConfiguration(dialect ConfigurationDialect, sentHex string) error { + spec, err := dialectSpecFor(dialect) if err != nil { - return 0, err + return err } - observedPayload, err := decodePort151Payload(observedTLVHex) + sent, err := common.HexStringToBytes(sentHex) if err != nil { - return 0, err + return err } + _, err = parseConfigurationSent(spec, sent) + return err +} - for _, setterTag := range requestedSetters { - if !compareConfigurationSetter(setterTag, sentPayload, observedPayload) { - return ConfigurationMismatch, nil - } +// BuildCurrentConfigurationRequest derives a getter-only readback from sent. +func BuildCurrentConfigurationRequest(dialect ConfigurationDialect, sentHex string) ([]byte, error) { + spec, err := dialectSpecFor(dialect) + if err != nil { + return nil, err + } + sent, err := common.HexStringToBytes(sentHex) + if err != nil { + return nil, err + } + requirements, err := parseConfigurationSent(spec, sent) + if err != nil { + return nil, err } - return ConfigurationMatch, nil + payload := []byte{configurationEnvelopeMarker, 0x00, 0x00} + for _, requirement := range orderedRequirements(sent, spec, requirements) { + payload = append(payload, requirement.spec.getterTag, 0x00) + } + payload[2] = byte((len(payload) - 3) / 2) + payload[1] = byte(len(payload) - 2) + return payload, nil } -func parseConfigurationSent(payload []byte) (tlvHex string, requestedSetters []byte, err error) { +func parseConfigurationSent(spec dialectSpec, payload []byte) (map[byte]configurationRequirement, error) { if len(payload) < 3 || payload[0] != configurationEnvelopeMarker { - return "", nil, errConfigurationWrongMarker + return nil, errConfigurationWrongMarker } - tlvPayload := []byte{configurationEnvelopeMarker, 0x00, 0x00} - setters := make(map[byte]struct{}) + requirements := make(map[byte]configurationRequirement) + commandCount := 0 offset := 3 for offset < len(payload) { tag, value, next, err := readTLV(payload, offset) if err != nil { - return "", nil, err + return nil, err } - - spec, ok := setterSpecs[tag] - if !ok { - return "", nil, errConfigurationUnsupportedCommand + commandCount++ + if commandCount > spec.limits.maxCommands { + return nil, errConfigurationTooLarge } - if len(value) != spec.valueLen { - return "", nil, errConfigurationMalformedTLV + + if setter, ok := spec.setters[tag]; ok { + if len(value) != setter.setterLen { + return nil, errConfigurationMalformedTLV + } + if err := validateCommandValue(setter, value); err != nil { + return nil, err + } + if existing, exists := requirements[setter.getterTag]; exists && existing.wantValue != nil { + return nil, errConfigurationDuplicateTag + } + copied := append([]byte(nil), value...) + requirements[setter.getterTag] = configurationRequirement{spec: setter, wantValue: copied} + offset = next + continue } - if tag == setterTagDataRate && value[0] > configurationMaxDataRate { - return "", nil, errConfigurationInvalidDataRate + + getter, ok := spec.getters[tag] + if !ok || len(value) != 0 { + return nil, errConfigurationUnsupportedCommand } - if _, exists := setters[tag]; exists { - return "", nil, errConfigurationDuplicateTag + if _, exists := requirements[getter.getterTag]; !exists { + requirements[getter.getterTag] = configurationRequirement{spec: getter} } - - setters[tag] = struct{}{} - requestedSetters = append(requestedSetters, tag) - tlvPayload = append(tlvPayload, spec.tlvTag, byte(len(value))) - tlvPayload = append(tlvPayload, value...) offset = next } - if len(requestedSetters) == 0 { - return "", nil, errConfigurationNoSetter + if len(requirements) == 0 { + return nil, errConfigurationNoSetter } - - return hex.EncodeToString(tlvPayload), requestedSetters, nil + if expectedResponseSize(requirements) > spec.limits.maxResponseBytes { + return nil, errConfigurationTooLarge + } + return requirements, nil } -func parseConfigurationObserved(payload []byte) (tlvHex string, seen map[byte]struct{}, err error) { +func parseConfigurationObserved(requirements map[byte]configurationRequirement, payload []byte) (map[byte][][]byte, error) { if len(payload) < 3 || payload[0] != configurationEnvelopeMarker { - return "", nil, errConfigurationWrongMarker + return nil, errConfigurationWrongMarker } - tlvPayload := []byte{configurationEnvelopeMarker, 0x00, 0x00} - seen = make(map[byte]struct{}) + seen := make(map[byte][][]byte) offset := 3 for offset < len(payload) { tag, value, next, err := readTLV(payload, offset) if err != nil { - return "", nil, err + return nil, err } - - if spec, comparable := comparableTLVSpecs[tag]; comparable { - if len(value) != spec.valueLen { - return "", nil, errConfigurationMalformedTLV + requirement, required := requirements[tag] + if required { + if len(value) != requirement.spec.responseLen { + return nil, errConfigurationMalformedTLV } - if tag == tlvTagDataRate && value[0] > configurationMaxDataRate { - return "", nil, errConfigurationInvalidDataRate + if requirement.wantValue != nil && requirement.spec.dataRate && len(value) > 0 && value[0] > configurationMaxDataRate { + return nil, errConfigurationInvalidDataRate } - if _, exists := seen[tag]; exists { - return "", nil, errConfigurationDuplicateTag - } - seen[tag] = struct{}{} - tlvPayload = append(tlvPayload, tag, byte(len(value))) - tlvPayload = append(tlvPayload, value...) + seen[tag] = append(seen[tag], append([]byte(nil), value...)) } - offset = next } - - return hex.EncodeToString(tlvPayload), seen, nil + return seen, nil } -func decodePort151Payload(payloadHex string) (Port151Payload, error) { - config := port151PayloadConfig() - if err := common.ValidateLength(&payloadHex, &config); err != nil { - return Port151Payload{}, err +func validateCommandValue(spec commandSpec, value []byte) error { + if spec.dataRate && len(value) > 0 && value[0] > configurationMaxDataRate { + return errConfigurationInvalidDataRate } - - decoded, err := common.Decode(&payloadHex, &config) - if err != nil { - return Port151Payload{}, err + if spec.validate != nil { + return spec.validate(value) } + return nil +} - return decoded.(Port151Payload), nil +func compareRequirementValue(requirement configurationRequirement, observed []byte) bool { + sent := requirement.wantValue + if requirement.spec.prefixLen > 0 { + n := requirement.spec.prefixLen + if len(sent) < n || len(observed) < n { + return false + } + return bytes.Equal(sent[:n], observed[:n]) + } + if requirement.spec.mask != 0 { + if len(sent) == 0 || len(observed) == 0 { + return false + } + return (sent[0] & requirement.spec.mask) == (observed[0] & requirement.spec.mask) + } + return bytes.Equal(sent, observed) } -func compareConfigurationSetter(setterTag byte, sent, observed Port151Payload) bool { - switch setterTag { - case setterTagDeviceFlags: - return ptrEqual(sent.AccelerometerEnabled, observed.AccelerometerEnabled) && - ptrEqual(sent.WifiEnabled, observed.WifiEnabled) && - ptrEqual(sent.GnssEnabled, observed.GnssEnabled) && - ptrEqual(sent.FirmwareUpgrade, observed.FirmwareUpgrade) - case setterTagMovingIntervals: - return ptrEqual(sent.LocalizationIntervalWhileMoving, observed.LocalizationIntervalWhileMoving) && - ptrEqual(sent.LocalizationIntervalWhileSteady, observed.LocalizationIntervalWhileSteady) - case setterTagAccelerationThreshold: - return ptrEqual(sent.AccelerometerWakeupThreshold, observed.AccelerometerWakeupThreshold) && - ptrEqual(sent.AccelerometerDelay, observed.AccelerometerDelay) - case setterTagHeartbeatInterval: - return ptrEqual(sent.HeartbeatInterval, observed.HeartbeatInterval) - case setterTagAdvertisementInterval: - return ptrEqual(sent.AdvertisementFirmwareUpgradeInterval, observed.AdvertisementFirmwareUpgradeInterval) - case setterTagRotationFlags: - return ptrEqual(sent.RotationInvert, observed.RotationInvert) && - ptrEqual(sent.RotationConfirmed, observed.RotationConfirmed) - case setterTagDataRate: - return ptrEqual(sent.DataRate, observed.DataRate) - default: - return false +func expectedResponseSize(requirements map[byte]configurationRequirement) int { + size := 3 + for _, requirement := range requirements { + size += 2 + requirement.spec.responseLen } + return size } -func ptrEqual[T comparable](sent, observed *T) bool { - if sent == nil || observed == nil { - return sent == observed +func orderedRequirements(payload []byte, spec dialectSpec, requirements map[byte]configurationRequirement) []configurationRequirement { + ordered := make([]configurationRequirement, 0, len(requirements)) + seen := make(map[byte]struct{}, len(requirements)) + offset := 3 + for offset < len(payload) { + tag, _, next, err := readTLV(payload, offset) + if err != nil { + break + } + var getter byte + if setter, ok := spec.setters[tag]; ok { + getter = setter.getterTag + } else if getterSpec, ok := spec.getters[tag]; ok { + getter = getterSpec.getterTag + } + if getter != 0 { + if _, exists := seen[getter]; !exists { + if requirement, ok := requirements[getter]; ok { + ordered = append(ordered, requirement) + seen[getter] = struct{}{} + } + } + } + offset = next } - return *sent == *observed + return ordered } func readTLV(payload []byte, offset int) (tag byte, value []byte, next int, err error) { diff --git a/pkg/decoder/tagxl/v1/configuration_match_test.go b/pkg/decoder/tagxl/v1/configuration_match_test.go index 7f715c1..7b3d920 100644 --- a/pkg/decoder/tagxl/v1/configuration_match_test.go +++ b/pkg/decoder/tagxl/v1/configuration_match_test.go @@ -1,6 +1,8 @@ package tagxl import ( + "bytes" + "encoding/hex" "errors" "testing" @@ -176,16 +178,28 @@ func TestMatchConfiguration(t *testing.T) { _, err := MatchConfiguration("4c0602230118230118", "4c0301430118") assertConfigurationError(t, err, errConfigurationDuplicateTag) - _, err = MatchConfiguration("4c0401230118", "4c0602430118430118") - assertConfigurationError(t, err, errConfigurationDuplicateTag) + ok, err := MatchConfiguration("4c0401230118", "4c0602430118430118") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected matching duplicate observed setter responses") + } }) - t.Run("unsupported setter", func(t *testing.T) { - _, err := MatchConfiguration("4c01014000", "4c010140010f") + t.Run("unsupported runner", func(t *testing.T) { + _, err := MatchConfiguration("4c010180020005", "4c0101430118") assertConfigurationError(t, err, errConfigurationUnsupportedCommand) + }) - _, err = MatchConfiguration("4c010180020005", "4c0101430118") - assertConfigurationError(t, err, errConfigurationUnsupportedCommand) + t.Run("pure getter presence", func(t *testing.T) { + ok, err := MatchConfiguration("4c01014000", "4c010140010f") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected getter presence to match") + } }) t.Run("invalid data rate", func(t *testing.T) { @@ -372,3 +386,310 @@ func assertConfigurationError(t *testing.T, err error, target error) { t.Fatalf("expected error %v, got %v", target, err) } } + +func TestCompareConfigurationForDialects(t *testing.T) { + t.Run("tag xl firmware getter ignores value", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c01014600", "4c0101460411223344") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + + t.Run("tag xl mixed setter and getter", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c050220010a4600", "4c0a0240010a4604aabbccdd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + + t.Run("tag xl setter plus matching getter prefers setter equality", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c050220010a4000", "4c010140010b") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMismatch { + t.Fatalf("got %s, want mismatch", result) + } + }) + + t.Run("tag xl missing getter is incomplete", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c030246004000", "4c010140010a") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationIncomplete { + t.Fatalf("got %s, want incomplete", result) + } + }) + + t.Run("tag xl hold interval setter", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c01012702012c", "4c01014d02012c") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + + t.Run("tag xl rotation enable masks extra bits", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c01012b0181", "4c0101510101") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + + t.Run("smart label flags include ble bit", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c0101200110", "4c0101400110") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + + result, err = CompareConfigurationFor(ConfigurationDialectTagXL, "4c0101200110", "4c0101400100") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("tag xl should ignore ble bit: got %s", result) + } + }) + + t.Run("smart label motion compares threshold only", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c01012204006403e8", "4c01014204006407d0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + + result, err = CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c01012204006403e8", "4c01014204006507d0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMismatch { + t.Fatalf("got %s, want mismatch", result) + } + }) + + t.Run("smart label firmware getter", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c01014600", "4c0101460401020304") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + + t.Run("smart label heartbeat 169 is valid", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c01012301a9", "4c01014301a9") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + + t.Run("smart label rejects colliding tag xl rotation setter", func(t *testing.T) { + _, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c0101250103", "4c0101470103") + assertConfigurationError(t, err, errConfigurationUnsupportedCommand) + }) + + t.Run("smart label rejects nonresponsive scan timer", func(t *testing.T) { + _, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c01013203010078", "4c01016203010078") + assertConfigurationError(t, err, errConfigurationUnsupportedCommand) + }) + + t.Run("smart label rejects secret lte api key getter", func(t *testing.T) { + _, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, "4c0101b200", "4c0101b20401020304") + assertConfigurationError(t, err, errConfigurationUnsupportedCommand) + }) + + t.Run("wrong getter length is malformed", func(t *testing.T) { + _, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c01014600", "4c010146020011") + assertConfigurationError(t, err, errConfigurationMalformedTLV) + }) + + t.Run("duplicate observed setter values that differ mismatch", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c0101230118", "4c0602430118430119") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMismatch { + t.Fatalf("got %s, want mismatch", result) + } + }) + + t.Run("unknown dialect", func(t *testing.T) { + _, err := CompareConfigurationFor(ConfigurationDialectUnspecified, "4c0101230118", "4c0101430118") + assertConfigurationError(t, err, errConfigurationUnknownDialect) + }) +} + +func TestBuildCurrentConfigurationRequest(t *testing.T) { + t.Run("converts setters and keeps getters", func(t *testing.T) { + got, err := BuildCurrentConfigurationRequest(ConfigurationDialectTagXL, "4c0d0320010a2104007807084600") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want, err := hex.DecodeString("4c0703400041004600") + if err != nil { + t.Fatalf("decode want: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %x, want %x", got, want) + } + }) + + t.Run("deduplicates setter and getter of the same tag", func(t *testing.T) { + got, err := BuildCurrentConfigurationRequest(ConfigurationDialectTagXL, "4c050220010a4000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want, err := hex.DecodeString("4c03014000") + if err != nil { + t.Fatalf("decode want: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %x, want %x", got, want) + } + }) + + t.Run("smart label battery thresholds", func(t *testing.T) { + got, err := BuildCurrentConfigurationRequest(ConfigurationDialectSmartLabelV2, "4c01012902050a") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want, err := hex.DecodeString("4c03014c00") + if err != nil { + t.Fatalf("decode want: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %x, want %x", got, want) + } + }) + + t.Run("rejects oversized smart label response", func(t *testing.T) { + sent := hex.EncodeToString([]byte{ + 0x4c, 0x00, 0x00, + 0x2c, 0x10, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x30, 0x07, 0x00, 0x64, 0x00, 0x01, 0x02, 0x03, 0x04, + 0x21, 0x04, 0x01, 0x2c, 0x1c, 0x20, + 0x20, 0x01, 0x0f, + 0x23, 0x01, 0x06, + 0x46, 0x00, + 0x45, 0x00, + 0x49, 0x00, + 0x4a, 0x00, + 0x4b, 0x00, + }) + _, err := BuildCurrentConfigurationRequest(ConfigurationDialectSmartLabelV2, sent) + assertConfigurationError(t, err, errConfigurationTooLarge) + }) + + t.Run("round trip generated request", func(t *testing.T) { + request, err := BuildCurrentConfigurationRequest(ConfigurationDialectTagXL, "4c0101280102") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, hex.EncodeToString(request), "4c01014e0102") + if err != nil { + t.Fatalf("compare: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) +} + +func TestValidateConfiguration(t *testing.T) { + if err := ValidateConfiguration(ConfigurationDialectTagXL, "4c01014600"); err != nil { + t.Fatalf("expected getter-only profile to validate: %v", err) + } + if err := ValidateConfiguration(ConfigurationDialectSmartLabelV2, "4c0101250101"); err == nil { + t.Fatal("expected colliding Tag XL command to fail Smart Label validation") + } +} + +func TestConfigurationDialectString(t *testing.T) { + if ConfigurationDialectTagXL.String() != "tagxl" { + t.Fatalf("tag xl string = %q", ConfigurationDialectTagXL.String()) + } + if ConfigurationDialectSmartLabelV2.String() != "smartlabel-v2" { + t.Fatalf("smart label string = %q", ConfigurationDialectSmartLabelV2.String()) + } + if ConfigurationDialectUnspecified.String() != "unspecified" { + t.Fatalf("unspecified string = %q", ConfigurationDialectUnspecified.String()) + } +} + +func TestTagXLCommandMatrix(t *testing.T) { + cases := []struct { + name string + sent string + observed string + }{ + {name: "hold 27", sent: "4c01012702012c", observed: "4c01014d02012c"}, + {name: "buffer ack 29", sent: "4c0101290105", observed: "4c01014f0105"}, + {name: "timestamped 2a", sent: "4c01012a0103", observed: "4c0101500103"}, + {name: "rotation enable 2b", sent: "4c01012b0101", observed: "4c0101510101"}, + {name: "time 4c", sent: "4c01014c00", observed: "4c01014c0401020304"}, + {name: "records 52", sent: "4c01015200", observed: "4c01015206000101020304"}, + {name: "battery 45", sent: "4c01014500", observed: "4c010145020fa0"}, + {name: "reset count 49", sent: "4c01014900", observed: "4c01014902000c"}, + {name: "reset cause 4a", sent: "4c01014a00", observed: "4c01014a0400000001"}, + {name: "scan counts 4b", sent: "4c01014b00", observed: "4c01014b0400010002"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, tc.sent, tc.observed) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + } +} + +func TestSmartLabelCommandMatrix(t *testing.T) { + cases := []struct { + name string + sent string + observed string + }{ + {name: "battery levels 29", sent: "4c01012902050a", observed: "4c01014c02050a"}, + {name: "wifi ap 2a", sent: "4c01012a0103", observed: "4c01014d0103"}, + {name: "batch 2b", sent: "4c01012b0104", observed: "4c0101500104"}, + {name: "ble 2c", sent: "4c01012c10051e0afec10000000000000000000000", observed: "4c01015110051e0afec10000000000000000000000"}, + {name: "motion v2 30", sent: "4c0101300700640102030405", observed: "4c0101600700640102030405"}, + {name: "temp 61", sent: "4c01016100", observed: "4c0101610119"}, + {name: "buffer 63", sent: "4c01016300", observed: "4c01016302000a"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectSmartLabelV2, tc.sent, tc.observed) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + } +} diff --git a/pkg/decoder/tagxl/v1/port151.go b/pkg/decoder/tagxl/v1/port151.go index 3504dc6..3742627 100644 --- a/pkg/decoder/tagxl/v1/port151.go +++ b/pkg/decoder/tagxl/v1/port151.go @@ -88,14 +88,6 @@ var setterSpecs = map[byte]setterSpec{ setterTagDataRate: {tlvTag: tlvTagDataRate, valueLen: 1}, } -var comparableTLVSpecs = func() map[byte]setterSpec { - specs := make(map[byte]setterSpec, len(setterSpecs)) - for _, spec := range setterSpecs { - specs[spec.tlvTag] = spec - } - return specs -}() - type Port151Payload struct { AccelerometerEnabled *bool `json:"accelerometerEnabled"` WifiEnabled *bool `json:"wifiEnabled"` From 7fe6add8f677f38dce492b41df684edcd15ecf62 Mon Sep 17 00:00:00 2001 From: Niko Kriznik Date: Mon, 14 Sep 2026 10:19:04 +0000 Subject: [PATCH 2/4] test(tagxl): cover remaining Smart Label v2 command mappings Exercise every supported setter and getter so the dialect matrix cannot drift from the firmware command list. --- pkg/decoder/tagxl/v1/configuration_match_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/decoder/tagxl/v1/configuration_match_test.go b/pkg/decoder/tagxl/v1/configuration_match_test.go index 7b3d920..dcbda74 100644 --- a/pkg/decoder/tagxl/v1/configuration_match_test.go +++ b/pkg/decoder/tagxl/v1/configuration_match_test.go @@ -673,6 +673,16 @@ func TestSmartLabelCommandMatrix(t *testing.T) { sent string observed string }{ + {name: "flags 20", sent: "4c010120011f", observed: "4c010140011f"}, + {name: "intervals 21", sent: "4c01012104012c1c20", observed: "4c01014104012c1c20"}, + {name: "heartbeat 23", sent: "4c0101230106", observed: "4c0101430106"}, + {name: "advertisement 24", sent: "4c010124013c", observed: "4c010144013c"}, + {name: "data rate 28", sent: "4c0101280102", observed: "4c01014e0102"}, + {name: "battery 45", sent: "4c01014500", observed: "4c010145020fa0"}, + {name: "firmware 46", sent: "4c01014600", observed: "4c0101460401020304"}, + {name: "reset count 49", sent: "4c01014900", observed: "4c01014902000c"}, + {name: "reset cause 4a", sent: "4c01014a00", observed: "4c01014a0400000001"}, + {name: "scan counts 4b", sent: "4c01014b00", observed: "4c01014b0400010002"}, {name: "battery levels 29", sent: "4c01012902050a", observed: "4c01014c02050a"}, {name: "wifi ap 2a", sent: "4c01012a0103", observed: "4c01014d0103"}, {name: "batch 2b", sent: "4c01012b0104", observed: "4c0101500104"}, From d54a9747cd84bd7eebd8478d7d27a1624643707b Mon Sep 17 00:00:00 2001 From: Niko Kriznik Date: Tue, 15 Sep 2026 08:51:06 +0000 Subject: [PATCH 3/4] fix(tagxl): reject invalid getter data rates --- pkg/decoder/tagxl/v1/configuration_match.go | 5 ++- .../tagxl/v1/configuration_match_test.go | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/pkg/decoder/tagxl/v1/configuration_match.go b/pkg/decoder/tagxl/v1/configuration_match.go index 2fbc5aa..ac752bb 100644 --- a/pkg/decoder/tagxl/v1/configuration_match.go +++ b/pkg/decoder/tagxl/v1/configuration_match.go @@ -15,6 +15,7 @@ var ( errConfigurationDuplicateTag = errors.New("tag xl configuration: duplicate comparable tag") errConfigurationInvalidDataRate = errors.New("tag xl configuration: invalid data rate") errConfigurationTooLarge = errors.New("tag xl configuration: response exceeds dialect limit") + errConfigurationTooManyCommands = errors.New("tag xl configuration: command count exceeds dialect limit") errConfigurationUnknownDialect = errors.New("tag xl configuration: unknown dialect") ) @@ -156,7 +157,7 @@ func parseConfigurationSent(spec dialectSpec, payload []byte) (map[byte]configur } commandCount++ if commandCount > spec.limits.maxCommands { - return nil, errConfigurationTooLarge + return nil, errConfigurationTooManyCommands } if setter, ok := spec.setters[tag]; ok { @@ -211,7 +212,7 @@ func parseConfigurationObserved(requirements map[byte]configurationRequirement, if len(value) != requirement.spec.responseLen { return nil, errConfigurationMalformedTLV } - if requirement.wantValue != nil && requirement.spec.dataRate && len(value) > 0 && value[0] > configurationMaxDataRate { + if requirement.spec.dataRate && len(value) > 0 && value[0] > configurationMaxDataRate { return nil, errConfigurationInvalidDataRate } seen[tag] = append(seen[tag], append([]byte(nil), value...)) diff --git a/pkg/decoder/tagxl/v1/configuration_match_test.go b/pkg/decoder/tagxl/v1/configuration_match_test.go index dcbda74..dcc0bd8 100644 --- a/pkg/decoder/tagxl/v1/configuration_match_test.go +++ b/pkg/decoder/tagxl/v1/configuration_match_test.go @@ -208,6 +208,17 @@ func TestMatchConfiguration(t *testing.T) { _, err = MatchConfiguration("4c0101280102", "4c01014e0108") assertConfigurationError(t, err, errConfigurationInvalidDataRate) + + _, err = CompareConfigurationFor(ConfigurationDialectTagXL, "4c01014e00", "4c01014e0108") + assertConfigurationError(t, err, errConfigurationInvalidDataRate) + + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c01014e00", "4c01014e0102") + if err != nil { + t.Fatalf("valid getter-only data rate: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } }) t.Run("wrong observed comparable length", func(t *testing.T) { @@ -380,6 +391,29 @@ func TestPort151PayloadConfigCoversComparableTLVTags(t *testing.T) { } } +func TestTagXLDialectOverlapsPort151SetterSpecs(t *testing.T) { + spec, err := dialectSpecFor(ConfigurationDialectTagXL) + if err != nil { + t.Fatalf("dialect: %v", err) + } + for setter, port151 := range setterSpecs { + command, ok := spec.setters[setter] + if !ok { + t.Errorf("tagXLDialect missing setter 0x%02x", setter) + continue + } + if command.getterTag != port151.tlvTag { + t.Errorf("setter 0x%02x getterTag=0x%02x, want 0x%02x", setter, command.getterTag, port151.tlvTag) + } + if command.setterLen != port151.valueLen { + t.Errorf("setter 0x%02x setterLen=%d, want %d", setter, command.setterLen, port151.valueLen) + } + if command.responseLen != port151.valueLen { + t.Errorf("setter 0x%02x responseLen=%d, want %d", setter, command.responseLen, port151.valueLen) + } + } +} + func assertConfigurationError(t *testing.T, err error, target error) { t.Helper() if !errors.Is(err, target) { @@ -623,6 +657,12 @@ func TestValidateConfiguration(t *testing.T) { if err := ValidateConfiguration(ConfigurationDialectSmartLabelV2, "4c0101250101"); err == nil { t.Fatal("expected colliding Tag XL command to fail Smart Label validation") } + + payload := []byte{0x4c, 0x00, 0x00} + for range 33 { + payload = append(payload, tlvTagDeviceFlags, 0x00) + } + assertConfigurationError(t, ValidateConfiguration(ConfigurationDialectTagXL, hex.EncodeToString(payload)), errConfigurationTooManyCommands) } func TestConfigurationDialectString(t *testing.T) { From 5720cea0006118f644a441fd5383888df01189dd Mon Sep 17 00:00:00 2001 From: Niko Kriznik Date: Tue, 15 Sep 2026 09:01:52 +0000 Subject: [PATCH 4/4] fix(tagxl): reject out-of-range interval and acceleration setters --- pkg/decoder/tagxl/v1/configuration_dialect.go | 30 +++++++++++++++++-- .../tagxl/v1/configuration_match_test.go | 20 +++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pkg/decoder/tagxl/v1/configuration_dialect.go b/pkg/decoder/tagxl/v1/configuration_dialect.go index 0d9dead..01d4f30 100644 --- a/pkg/decoder/tagxl/v1/configuration_dialect.go +++ b/pkg/decoder/tagxl/v1/configuration_dialect.go @@ -75,10 +75,36 @@ func tagXLAdvertisementValidate(value []byte) error { return nil } +func tagXLMovingIntervalsValidate(value []byte) error { + if len(value) != 4 { + return common.ErrValidationFailed + } + packed := common.BytesToUint32(value) + moving := packed >> 16 + steady := packed & 0xffff + if moving < 60 || moving > 86400 || steady < 120 || steady > 86400 { + return common.ErrValidationFailed + } + return nil +} + +func tagXLAccelerationValidate(value []byte) error { + if len(value) != 4 { + return common.ErrValidationFailed + } + packed := common.BytesToUint32(value) + threshold := packed >> 16 + delay := packed & 0xffff + if threshold < 10 || threshold > 8000 || delay < 1000 || delay > 10000 { + return common.ErrValidationFailed + } + return nil +} + var tagXLDialect = newDialectSpec(dialectLimits{maxCommands: 32, maxResponseBytes: 255}, []commandSpec{ {setterTag: setterTagDeviceFlags, getterTag: tlvTagDeviceFlags, setterLen: 1, responseLen: 1, mask: 0x0f}, - {setterTag: setterTagMovingIntervals, getterTag: tlvTagMovingIntervals, setterLen: 4, responseLen: 4}, - {setterTag: setterTagAccelerationThreshold, getterTag: tlvTagAccelerationThreshold, setterLen: 4, responseLen: 4}, + {setterTag: setterTagMovingIntervals, getterTag: tlvTagMovingIntervals, setterLen: 4, responseLen: 4, validate: tagXLMovingIntervalsValidate}, + {setterTag: setterTagAccelerationThreshold, getterTag: tlvTagAccelerationThreshold, setterLen: 4, responseLen: 4, validate: tagXLAccelerationValidate}, {setterTag: setterTagHeartbeatInterval, getterTag: tlvTagHeartbeatInterval, setterLen: 1, responseLen: 1, validate: tagXLHeartbeatValidate}, {setterTag: setterTagAdvertisementInterval, getterTag: tlvTagAdvertisementInterval, setterLen: 1, responseLen: 1, validate: tagXLAdvertisementValidate}, {setterTag: setterTagRotationFlags, getterTag: tlvTagRotationFlags, setterLen: 1, responseLen: 1, mask: 0x03}, diff --git a/pkg/decoder/tagxl/v1/configuration_match_test.go b/pkg/decoder/tagxl/v1/configuration_match_test.go index dcc0bd8..cff1666 100644 --- a/pkg/decoder/tagxl/v1/configuration_match_test.go +++ b/pkg/decoder/tagxl/v1/configuration_match_test.go @@ -281,6 +281,26 @@ func TestMatchConfiguration(t *testing.T) { t.Fatalf("expected validation error, got %v", err) } }) + + t.Run("moving interval 59", func(t *testing.T) { + ok, err := MatchConfiguration("4c01012104003b1c20", "4c01014104003b1c20") + if ok { + t.Fatal("expected validation failure, not verification") + } + if !errors.Is(err, common.ErrValidationFailed) { + t.Fatalf("expected validation error, got %v", err) + } + }) + + t.Run("acceleration delay 999", func(t *testing.T) { + ok, err := MatchConfiguration("4c01012204006403e7", "4c01014204006403e7") + if ok { + t.Fatal("expected validation failure, not verification") + } + if !errors.Is(err, common.ErrValidationFailed) { + t.Fatalf("expected validation error, got %v", err) + } + }) }) }