diff --git a/pkg/decoder/tagsl/v1/configuration_match.go b/pkg/decoder/tagsl/v1/configuration_match.go index 1abe0aa..d3a5e2c 100644 --- a/pkg/decoder/tagsl/v1/configuration_match.go +++ b/pkg/decoder/tagsl/v1/configuration_match.go @@ -50,6 +50,19 @@ func MatchConfiguration(sentHex, observedHex string) (bool, error) { return configurationMatches(sentPayload, observedPayload), nil } +// ValidateConfiguration reports whether sent is a well-formed port-128 configuration downlink. +func ValidateConfiguration(sentHex string) error { + sent, err := common.HexStringToBytes(sentHex) + if err != nil { + return err + } + if len(sent) != configurationSentByteLength { + return fmt.Errorf("%w: sent configuration payload length %d", common.ErrInvalidPayloadLength, len(sent)) + } + _, err = decodePort128Payload(sentHex) + return err +} + func validConfigurationReportLength(n int) bool { switch n { case configurationReportCoreByteLength, configurationReportWithBatchByteLength, configurationReportFullByteLength: @@ -140,6 +153,19 @@ func MatchBleConfiguration(sentHex, observedHex string) (bool, error) { return sentPayload == observedPayload, nil } +// ValidateBleConfiguration reports whether sent is a well-formed port-134 BLE configuration downlink. +func ValidateBleConfiguration(sentHex string) error { + sent, err := common.HexStringToBytes(sentHex) + if err != nil { + return err + } + if len(sent) != bleConfigurationByteLength { + return fmt.Errorf("%w: sent BLE configuration payload length %d", common.ErrInvalidPayloadLength, len(sent)) + } + _, err = decodePort8Payload(sentHex) + return err +} + func decodePort8Payload(payloadHex string) (Port8Payload, error) { config := port8PayloadConfig() decoded, err := common.Decode(&payloadHex, &config) diff --git a/pkg/decoder/tagsl/v1/configuration_match_test.go b/pkg/decoder/tagsl/v1/configuration_match_test.go index 2e5199a..6082f9a 100644 --- a/pkg/decoder/tagsl/v1/configuration_match_test.go +++ b/pkg/decoder/tagsl/v1/configuration_match_test.go @@ -259,6 +259,40 @@ func assertConfigurationError(t *testing.T, err error, target error) { } } +func TestValidateConfiguration(t *testing.T) { + if err := ValidateConfiguration(tagSLSentHex); err != nil { + t.Fatalf("expected valid sent payload: %v", err) + } + + if err := ValidateConfiguration("zz"); err == nil { + t.Fatal("expected hex error") + } + + err := ValidateConfiguration(tagSLSentHex[:len(tagSLSentHex)-2]) + assertConfigurationError(t, err, common.ErrInvalidPayloadLength) + + sent := mustMutateHex(t, tagSLSentHex, map[int][]byte{3: {0x00, 0x00, 0x00, 0x04}}) + if err := ValidateConfiguration(sent); !errors.Is(err, common.ErrValidationFailed) { + t.Fatalf("expected validation error, got %v", err) + } +} + +func TestValidateBleConfiguration(t *testing.T) { + if err := ValidateBleConfiguration(tagSLBleSentHex); err != nil { + t.Fatalf("expected valid BLE payload: %v", err) + } + + if err := ValidateBleConfiguration("zz"); err == nil { + t.Fatal("expected hex error") + } + + err := ValidateBleConfiguration(tagSLBleSentHex[:len(tagSLBleSentHex)-2]) + assertConfigurationError(t, err, common.ErrInvalidPayloadLength) + + err = ValidateBleConfiguration(tagSLBleSentHex + "00") + assertConfigurationError(t, err, common.ErrInvalidPayloadLength) +} + func mustMutateHex(t *testing.T, payloadHex string, replacements map[int][]byte) string { t.Helper() payload, err := hex.DecodeString(payloadHex) diff --git a/pkg/decoder/tagxl/v1/configuration_dialect.go b/pkg/decoder/tagxl/v1/configuration_dialect.go index 01d4f30..f9e0397 100644 --- a/pkg/decoder/tagxl/v1/configuration_dialect.go +++ b/pkg/decoder/tagxl/v1/configuration_dialect.go @@ -42,13 +42,18 @@ type dialectSpec struct { limits dialectLimits setters map[byte]commandSpec getters map[byte]commandSpec + actions map[byte]int } -func newDialectSpec(limits dialectLimits, commands []commandSpec) dialectSpec { +func newDialectSpec(limits dialectLimits, commands []commandSpec, actions map[byte]int) dialectSpec { spec := dialectSpec{ limits: limits, setters: make(map[byte]commandSpec, len(commands)), getters: make(map[byte]commandSpec, len(commands)), + actions: actions, + } + if spec.actions == nil { + spec.actions = map[byte]int{} } for _, command := range commands { if command.setterTag != 0 { @@ -120,6 +125,12 @@ var tagXLDialect = newDialectSpec(dialectLimits{maxCommands: 32, maxResponseByte {getterTag: tlvTagScanCounts, responseLen: 4}, {getterTag: 0x4c, responseLen: 4}, {getterTag: 0x52, responseLen: 6}, +}, map[byte]int{ + actionTagAlarm: 2, + actionTagResetDevice: 0, + actionTagScanNow: 0, + actionTagClearStorage: 0, + actionTagWipeAll: 0, }) var smartLabelV2Dialect = newDialectSpec(dialectLimits{maxCommands: 30, maxResponseBytes: 51}, []commandSpec{ @@ -141,6 +152,11 @@ var smartLabelV2Dialect = newDialectSpec(dialectLimits{maxCommands: 30, maxRespo {getterTag: 0x4b, responseLen: 4}, {getterTag: 0x61, responseLen: 1}, {getterTag: 0x63, responseLen: 2}, +}, map[byte]int{ + actionTagResetDevice: 0, + actionTagScanNow: 0, + actionTagClearStorage: 0, + actionTagWipeAll: 0, }) func dialectSpecFor(dialect ConfigurationDialect) (dialectSpec, error) { diff --git a/pkg/decoder/tagxl/v1/configuration_match.go b/pkg/decoder/tagxl/v1/configuration_match.go index ac752bb..27c735d 100644 --- a/pkg/decoder/tagxl/v1/configuration_match.go +++ b/pkg/decoder/tagxl/v1/configuration_match.go @@ -50,6 +50,11 @@ type configurationRequirement struct { wantValue []byte } +type sentConfiguration struct { + requirements map[byte]configurationRequirement + hasActions bool +} + // MatchConfiguration reports whether observed reflects every setter in sent // using the Tag XL dialect. func MatchConfiguration(sentHex, observedHex string) (bool, error) { @@ -77,21 +82,24 @@ func CompareConfigurationFor(dialect ConfigurationDialect, sentHex, observedHex return 0, err } - requirements, err := parseConfigurationSent(spec, sent) + parsed, err := parseConfigurationSent(spec, sent) if err != nil { return 0, err } - seen, err := parseConfigurationObserved(requirements, observed) + if len(parsed.requirements) == 0 { + return 0, errConfigurationNoSetter + } + seen, err := parseConfigurationObserved(parsed.requirements, observed) if err != nil { return 0, err } - for tag := range requirements { + for tag := range parsed.requirements { if _, ok := seen[tag]; !ok { return ConfigurationIncomplete, nil } } - for tag, requirement := range requirements { + for tag, requirement := range parsed.requirements { if requirement.wantValue == nil { continue } @@ -104,17 +112,29 @@ func CompareConfigurationFor(dialect ConfigurationDialect, sentHex, observedHex return ConfigurationMatch, nil } -// ValidateConfiguration reports whether sent is a verifiable payload for dialect. -func ValidateConfiguration(dialect ConfigurationDialect, sentHex string) error { +// AnalyzeConfiguration classifies a sent payload without comparing an uplink. +// hasObservableRequirements is true when at least one setter or getter is present. +// hasActions is true when at least one supported action command is present. +func AnalyzeConfiguration(dialect ConfigurationDialect, sentHex string) (hasObservableRequirements, hasActions bool, err error) { spec, err := dialectSpecFor(dialect) if err != nil { - return err + return false, false, err } sent, err := common.HexStringToBytes(sentHex) if err != nil { - return err + return false, false, err + } + parsed, err := parseConfigurationSent(spec, sent) + if err != nil { + return false, false, err } - _, err = parseConfigurationSent(spec, sent) + return len(parsed.requirements) > 0, parsed.hasActions, nil +} + +// ValidateConfiguration reports whether sent is a syntactically valid payload for dialect. +// Action-only payloads are valid. +func ValidateConfiguration(dialect ConfigurationDialect, sentHex string) error { + _, _, err := AnalyzeConfiguration(dialect, sentHex) return err } @@ -128,13 +148,16 @@ func BuildCurrentConfigurationRequest(dialect ConfigurationDialect, sentHex stri if err != nil { return nil, err } - requirements, err := parseConfigurationSent(spec, sent) + parsed, err := parseConfigurationSent(spec, sent) if err != nil { return nil, err } + if len(parsed.requirements) == 0 { + return nil, errConfigurationNoSetter + } payload := []byte{configurationEnvelopeMarker, 0x00, 0x00} - for _, requirement := range orderedRequirements(sent, spec, requirements) { + for _, requirement := range orderedRequirements(sent, spec, parsed.requirements) { payload = append(payload, requirement.spec.getterTag, 0x00) } payload[2] = byte((len(payload) - 3) / 2) @@ -142,33 +165,46 @@ func BuildCurrentConfigurationRequest(dialect ConfigurationDialect, sentHex stri return payload, nil } -func parseConfigurationSent(spec dialectSpec, payload []byte) (map[byte]configurationRequirement, error) { +func parseConfigurationSent(spec dialectSpec, payload []byte) (sentConfiguration, error) { if len(payload) < 3 || payload[0] != configurationEnvelopeMarker { - return nil, errConfigurationWrongMarker + return sentConfiguration{}, errConfigurationWrongMarker } requirements := make(map[byte]configurationRequirement) + seenActions := make(map[byte]struct{}) commandCount := 0 offset := 3 for offset < len(payload) { tag, value, next, err := readTLV(payload, offset) if err != nil { - return nil, err + return sentConfiguration{}, err } commandCount++ if commandCount > spec.limits.maxCommands { - return nil, errConfigurationTooManyCommands + return sentConfiguration{}, errConfigurationTooManyCommands + } + + if actionLen, ok := spec.actions[tag]; ok { + if len(value) != actionLen { + return sentConfiguration{}, errConfigurationMalformedTLV + } + if _, exists := seenActions[tag]; exists { + return sentConfiguration{}, errConfigurationDuplicateTag + } + seenActions[tag] = struct{}{} + offset = next + continue } if setter, ok := spec.setters[tag]; ok { if len(value) != setter.setterLen { - return nil, errConfigurationMalformedTLV + return sentConfiguration{}, errConfigurationMalformedTLV } if err := validateCommandValue(setter, value); err != nil { - return nil, err + return sentConfiguration{}, err } if existing, exists := requirements[setter.getterTag]; exists && existing.wantValue != nil { - return nil, errConfigurationDuplicateTag + return sentConfiguration{}, errConfigurationDuplicateTag } copied := append([]byte(nil), value...) requirements[setter.getterTag] = configurationRequirement{spec: setter, wantValue: copied} @@ -178,7 +214,7 @@ func parseConfigurationSent(spec dialectSpec, payload []byte) (map[byte]configur getter, ok := spec.getters[tag] if !ok || len(value) != 0 { - return nil, errConfigurationUnsupportedCommand + return sentConfiguration{}, errConfigurationUnsupportedCommand } if _, exists := requirements[getter.getterTag]; !exists { requirements[getter.getterTag] = configurationRequirement{spec: getter} @@ -186,13 +222,13 @@ func parseConfigurationSent(spec dialectSpec, payload []byte) (map[byte]configur offset = next } - if len(requirements) == 0 { - return nil, errConfigurationNoSetter + if len(requirements) == 0 && len(seenActions) == 0 { + return sentConfiguration{}, errConfigurationNoSetter } if expectedResponseSize(requirements) > spec.limits.maxResponseBytes { - return nil, errConfigurationTooLarge + return sentConfiguration{}, errConfigurationTooLarge } - return requirements, nil + return sentConfiguration{requirements: requirements, hasActions: len(seenActions) > 0}, nil } func parseConfigurationObserved(requirements map[byte]configurationRequirement, payload []byte) (map[byte][][]byte, error) { diff --git a/pkg/decoder/tagxl/v1/configuration_match_test.go b/pkg/decoder/tagxl/v1/configuration_match_test.go index cff1666..ea9b0ac 100644 --- a/pkg/decoder/tagxl/v1/configuration_match_test.go +++ b/pkg/decoder/tagxl/v1/configuration_match_test.go @@ -187,11 +187,16 @@ func TestMatchConfiguration(t *testing.T) { } }) - t.Run("unsupported runner", func(t *testing.T) { - _, err := MatchConfiguration("4c010180020005", "4c0101430118") + t.Run("unsupported command", func(t *testing.T) { + _, err := MatchConfiguration("4c01018500", "4c0101430118") assertConfigurationError(t, err, errConfigurationUnsupportedCommand) }) + t.Run("action-only has no comparable requirement", func(t *testing.T) { + _, err := MatchConfiguration("4c010180020005", "4c0101430118") + assertConfigurationError(t, err, errConfigurationNoSetter) + }) + t.Run("pure getter presence", func(t *testing.T) { ok, err := MatchConfiguration("4c01014000", "4c010140010f") if err != nil { @@ -674,6 +679,9 @@ 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(ConfigurationDialectTagXL, "4c01018100"); err != nil { + t.Fatalf("expected action-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") } @@ -727,6 +735,175 @@ func TestTagXLCommandMatrix(t *testing.T) { } } +func TestAnalyzeConfiguration(t *testing.T) { + cases := []struct { + name string + dialect ConfigurationDialect + sent string + observable bool + hasActions bool + wantErr error + }{ + {name: "tag xl setter", dialect: ConfigurationDialectTagXL, sent: "4c0101230118", observable: true}, + {name: "tag xl getter", dialect: ConfigurationDialectTagXL, sent: "4c01014600", observable: true}, + {name: "tag xl alarm", dialect: ConfigurationDialectTagXL, sent: "4c010180020005", hasActions: true}, + {name: "tag xl reset", dialect: ConfigurationDialectTagXL, sent: "4c01018100", hasActions: true}, + {name: "tag xl scan", dialect: ConfigurationDialectTagXL, sent: "4c01018200", hasActions: true}, + {name: "tag xl clear storage", dialect: ConfigurationDialectTagXL, sent: "4c01018300", hasActions: true}, + {name: "tag xl wipe all", dialect: ConfigurationDialectTagXL, sent: "4c01018400", hasActions: true}, + {name: "tag xl mixed setter and reset", dialect: ConfigurationDialectTagXL, sent: "4c0a02210400780e108100", observable: true, hasActions: true}, + {name: "tag xl mixed getter and reset", dialect: ConfigurationDialectTagXL, sent: "4c030140008100", observable: true, hasActions: true}, + {name: "tag xl multiple actions", dialect: ConfigurationDialectTagXL, sent: "4c030281008200", hasActions: true}, + {name: "tag xl empty", dialect: ConfigurationDialectTagXL, sent: "4c0000", wantErr: errConfigurationNoSetter}, + {name: "tag xl unknown", dialect: ConfigurationDialectTagXL, sent: "4c01018500", wantErr: errConfigurationUnsupportedCommand}, + {name: "smart label reset", dialect: ConfigurationDialectSmartLabelV2, sent: "4c01018100", hasActions: true}, + {name: "smart label scan", dialect: ConfigurationDialectSmartLabelV2, sent: "4c01018200", hasActions: true}, + {name: "smart label clear storage", dialect: ConfigurationDialectSmartLabelV2, sent: "4c01018300", hasActions: true}, + {name: "smart label wipe all", dialect: ConfigurationDialectSmartLabelV2, sent: "4c01018400", hasActions: true}, + {name: "smart label alarm", dialect: ConfigurationDialectSmartLabelV2, sent: "4c010180020005", wantErr: errConfigurationUnsupportedCommand}, + {name: "unknown dialect", dialect: ConfigurationDialectUnspecified, sent: "4c01018100", wantErr: errConfigurationUnknownDialect}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + observable, hasActions, err := AnalyzeConfiguration(tc.dialect, tc.sent) + if tc.wantErr != nil { + assertConfigurationError(t, err, tc.wantErr) + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if observable != tc.observable || hasActions != tc.hasActions { + t.Fatalf("got observable=%v actions=%v, want observable=%v actions=%v", observable, hasActions, tc.observable, tc.hasActions) + } + }) + } +} + +func TestConfigurationActions(t *testing.T) { + t.Run("wrong action lengths", func(t *testing.T) { + cases := []struct { + name string + dialect ConfigurationDialect + sent string + }{ + {name: "tag xl alarm empty", dialect: ConfigurationDialectTagXL, sent: "4c01018000"}, + {name: "tag xl reset with value", dialect: ConfigurationDialectTagXL, sent: "4c010181020000"}, + {name: "tag xl scan with value", dialect: ConfigurationDialectTagXL, sent: "4c01018201ff"}, + {name: "tag xl wipe all with value", dialect: ConfigurationDialectTagXL, sent: "4c01018401ff"}, + {name: "smart label reset with value", dialect: ConfigurationDialectSmartLabelV2, sent: "4c01018101ff"}, + {name: "smart label wipe all with value", dialect: ConfigurationDialectSmartLabelV2, sent: "4c01018401ff"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assertConfigurationError(t, ValidateConfiguration(tc.dialect, tc.sent), errConfigurationMalformedTLV) + }) + } + }) + + t.Run("duplicate action", func(t *testing.T) { + assertConfigurationError(t, ValidateConfiguration(ConfigurationDialectTagXL, "4c030281008100"), errConfigurationDuplicateTag) + assertConfigurationError(t, ValidateConfiguration(ConfigurationDialectSmartLabelV2, "4c030282008200"), errConfigurationDuplicateTag) + }) + + t.Run("compare mixed setter and action", func(t *testing.T) { + cases := []struct { + name string + dialect ConfigurationDialect + sent string + observed string + }{ + {name: "action after setter", dialect: ConfigurationDialectTagXL, sent: "4c0a022104012c1c208100", observed: "4c01014104012c1c20"}, + {name: "action before setter", dialect: ConfigurationDialectTagXL, sent: "4c0a0281002104012c1c20", observed: "4c01014104012c1c20"}, + {name: "action between setter and getter", dialect: ConfigurationDialectTagXL, sent: "4c0d032104012c1c2081004000", observed: "4c05024104012c1c2040010f"}, + {name: "smart label setter and reset", dialect: ConfigurationDialectSmartLabelV2, sent: "4c05022001108100", observed: "4c0101400110"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := CompareConfigurationFor(tc.dialect, tc.sent, tc.observed) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + } + }) + + t.Run("observed scan acknowledgement is ignored", func(t *testing.T) { + result, err := CompareConfigurationFor(ConfigurationDialectTagXL, "4c0a022104012c1c208200", "4c05024104012c1c208200") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != ConfigurationMatch { + t.Fatalf("got %s, want match", result) + } + }) + + t.Run("compare and build reject action-only", func(t *testing.T) { + assertConfigurationError(t, mustCompareErr(t, ConfigurationDialectTagXL, "4c01018100", "4c010140010f"), errConfigurationNoSetter) + assertConfigurationError(t, mustCompareErr(t, ConfigurationDialectSmartLabelV2, "4c01018200", "4c0101400110"), errConfigurationNoSetter) + _, err := BuildCurrentConfigurationRequest(ConfigurationDialectTagXL, "4c01018100") + assertConfigurationError(t, err, errConfigurationNoSetter) + }) + + t.Run("readback omits actions", func(t *testing.T) { + got, err := BuildCurrentConfigurationRequest(ConfigurationDialectTagXL, "4c0d0320010a2104007807088100") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want, err := hex.DecodeString("4c050240004100") + if err != nil { + t.Fatalf("decode want: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %x, want %x", got, want) + } + }) + + t.Run("actions count toward command limits", func(t *testing.T) { + payload := []byte{0x4c, 0x00, 0x00} + for range 31 { + payload = append(payload, tlvTagDeviceFlags, 0x00) + } + payload = append(payload, actionTagResetDevice, 0x00) + if err := ValidateConfiguration(ConfigurationDialectTagXL, hex.EncodeToString(payload)); err != nil { + t.Fatalf("32 commands including one action should validate: %v", err) + } + payload = append(payload, actionTagScanNow, 0x00) + assertConfigurationError(t, ValidateConfiguration(ConfigurationDialectTagXL, hex.EncodeToString(payload)), errConfigurationTooManyCommands) + }) + + t.Run("actions do not count toward response size", 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, + 0x81, 0x00, + 0x82, 0x00, + 0x83, 0x00, + }) + observable, hasActions, err := AnalyzeConfiguration(ConfigurationDialectSmartLabelV2, sent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !observable || !hasActions { + t.Fatalf("got observable=%v actions=%v", observable, hasActions) + } + }) +} + +func mustCompareErr(t *testing.T, dialect ConfigurationDialect, sent, observed string) error { + t.Helper() + _, err := CompareConfigurationFor(dialect, sent, observed) + return err +} + func TestSmartLabelCommandMatrix(t *testing.T) { cases := []struct { name string diff --git a/pkg/decoder/tagxl/v1/port151.go b/pkg/decoder/tagxl/v1/port151.go index 3742627..acfa618 100644 --- a/pkg/decoder/tagxl/v1/port151.go +++ b/pkg/decoder/tagxl/v1/port151.go @@ -59,6 +59,12 @@ const ( setterTagRotationFlags = 0x25 setterTagDataRate = 0x28 + actionTagAlarm = 0x80 + actionTagResetDevice = 0x81 + actionTagScanNow = 0x82 + actionTagClearStorage = 0x83 + actionTagWipeAll = 0x84 + tlvTagDeviceFlags = 0x40 tlvTagMovingIntervals = 0x41 tlvTagAccelerationThreshold = 0x42