diff --git a/.secrets.baseline b/.secrets.baseline index 001aef9..46edd02 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1469,7 +1469,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "55472b68d8a8560add30831739dd3552e63d5b33", "is_verified": false, - "line_number": 420, + "line_number": 442, "is_secret": false }, { @@ -1477,7 +1477,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "463609dcc13b7b90fcf29ca237191ad5bf977c46", "is_verified": false, - "line_number": 431, + "line_number": 453, "is_secret": false }, { @@ -1485,7 +1485,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "4c35a80282e5237761aeb3b9b2c8d422b16df653", "is_verified": false, - "line_number": 443, + "line_number": 465, "is_secret": false }, { @@ -1493,7 +1493,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "5bcf7c2f08e382a84f0a78f1c6aa91f711806aa8", "is_verified": false, - "line_number": 456, + "line_number": 478, "is_secret": false }, { @@ -1501,7 +1501,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "0560cb6af09786d2305b91018ca587c93c0d7dbd", "is_verified": false, - "line_number": 470, + "line_number": 492, "is_secret": false }, { @@ -1509,7 +1509,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "66cd8eba7181b16377a615d793be286a3aacb087", "is_verified": false, - "line_number": 480, + "line_number": 502, "is_secret": false }, { @@ -1517,7 +1517,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "a6c92fb0cd83e9a6f6f2bd5bfdb1a297dfe7a502", "is_verified": false, - "line_number": 492, + "line_number": 514, "is_secret": false }, { @@ -1525,7 +1525,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "c0e35b955de71e6fe09016adf1216ed73f1d7a8b", "is_verified": false, - "line_number": 506, + "line_number": 528, "is_secret": false }, { @@ -1533,7 +1533,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "5a0861255e90d61193afbc62ee5b7924739d1b54", "is_verified": false, - "line_number": 522, + "line_number": 544, "is_secret": false }, { @@ -1541,7 +1541,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "1aa68aee442b8b1c5c9fdca3fc2e18ed2f84a637", "is_verified": false, - "line_number": 540, + "line_number": 562, "is_secret": false }, { @@ -1549,7 +1549,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "bb265a97223c679953c85c60d61907ee7683468e", "is_verified": false, - "line_number": 694, + "line_number": 716, "is_secret": false }, { @@ -1557,7 +1557,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "4df89cb03f258ca60c13bf53e3442d60826bacf7", "is_verified": false, - "line_number": 700, + "line_number": 722, "is_secret": false }, { @@ -1565,7 +1565,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "f5188ea01f60dd0e30b8ff8126123c81f38ba425", "is_verified": false, - "line_number": 711, + "line_number": 733, "is_secret": false }, { @@ -1573,7 +1573,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "164c11e5bb3bdbb53a3682942846936da8006274", "is_verified": false, - "line_number": 723, + "line_number": 745, "is_secret": false }, { @@ -1581,7 +1581,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "39e57284237493c8386cbfebd10364b4f25b86bd", "is_verified": false, - "line_number": 736, + "line_number": 758, "is_secret": false }, { @@ -1589,7 +1589,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "d4fc2a168f60a698eef5c40e42f7147798791b70", "is_verified": false, - "line_number": 750, + "line_number": 772, "is_secret": false }, { @@ -1597,7 +1597,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "0c24951224219592f4f044aa8c1a43cd87d14bae", "is_verified": false, - "line_number": 765, + "line_number": 787, "is_secret": false }, { @@ -1605,7 +1605,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "67dfa780930cf12323bf6d3a2737f8be7168d2e7", "is_verified": false, - "line_number": 776, + "line_number": 798, "is_secret": false }, { @@ -1613,7 +1613,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "b821604371f934e1ce969c042520adc0f69859bf", "is_verified": false, - "line_number": 789, + "line_number": 811, "is_secret": false }, { @@ -1621,7 +1621,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "737544481bcf878548b5d3cef6898ebaaa307e35", "is_verified": false, - "line_number": 804, + "line_number": 826, "is_secret": false }, { @@ -1629,7 +1629,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "56bdd17763f2ca6b25584e70ca4888acd267da77", "is_verified": false, - "line_number": 821, + "line_number": 843, "is_secret": false }, { @@ -1637,7 +1637,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "56f27e2c927e138a36b3cb7d07b942da7667b8f2", "is_verified": false, - "line_number": 846, + "line_number": 868, "is_secret": false }, { @@ -1645,7 +1645,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "516dead2735f9bcd1eced3f678aa6dbb0ed87c86", "is_verified": false, - "line_number": 1080, + "line_number": 1198, "is_secret": false }, { @@ -1653,7 +1653,7 @@ "filename": "pkg/decoder/tagxl/v1/decoder_test.go", "hashed_secret": "884f72cf02528dcd37a031e2af8575273d4394e2", "is_verified": false, - "line_number": 1263, + "line_number": 1397, "is_secret": false } ], @@ -1759,7 +1759,7 @@ "filename": "pkg/decoder/tagxl/v1/examples_test.go", "hashed_secret": "632ee27a7e117b634cb0fb234f7f7d199db5d5d1", "is_verified": false, - "line_number": 270, + "line_number": 267, "is_secret": false }, { @@ -1767,7 +1767,7 @@ "filename": "pkg/decoder/tagxl/v1/examples_test.go", "hashed_secret": "9e1335b0e4c10057a072e6aa67e5cfb9d0e5d324", "is_verified": false, - "line_number": 271, + "line_number": 268, "is_secret": false } ], @@ -2228,5 +2228,5 @@ } ] }, - "generated_at": "2026-02-23T10:43:59Z" + "generated_at": "2026-02-23T16:22:43Z" } diff --git a/pkg/decoder/tagxl/v1/decoder.go b/pkg/decoder/tagxl/v1/decoder.go index 7134f2e..ab5db0c 100644 --- a/pkg/decoder/tagxl/v1/decoder.go +++ b/pkg/decoder/tagxl/v1/decoder.go @@ -22,7 +22,7 @@ type TagXLv1Decoder struct { solver solver.SolverV1 fallbackSolver solver.SolverV1 - // Preferred v2 solver (used for GNSS NAV grouping ports 192/193/194/195/199 when available) + // Preferred v2 solver (used for GNSS NAV grouping ports 192/193/194/195/199/210/211 when available) v2Solver solver.SolverV2 fallbackV2Solver solver.SolverV2 } @@ -366,37 +366,127 @@ func (t TagXLv1Decoder) getConfig(port uint8, payload []byte) (common.PayloadCon default: return common.PayloadConfig{}, fmt.Errorf("%w: version %v for port %d not supported", common.ErrPortNotSupported, version, port) } + case 212: + if len(payload) < Port212HeaderLength { + return common.PayloadConfig{}, common.ErrPayloadTooShort + } + var version = payload[Port212VersionIndex] + switch version { + case Port212Version1: + return common.PayloadConfig{ + Fields: []common.FieldConfig{ + {Name: "Timestamp", Start: 0, Length: 4, Transform: timestamp}, + {Name: "Version", Start: 4, Length: 1}, + {Name: "Moving", Start: 4, Length: 1, Transform: alwaysFalse}, + {Name: "Mac1", Start: 5, Length: 6, Hex: true}, + {Name: "Mac2", Start: 11, Length: 6, Optional: true, Hex: true}, + {Name: "Mac3", Start: 17, Length: 6, Optional: true, Hex: true}, + {Name: "Mac4", Start: 23, Length: 6, Optional: true, Hex: true}, + {Name: "Mac5", Start: 29, Length: 6, Optional: true, Hex: true}, + }, + TargetType: reflect.TypeOf(Port212Payload{}), + Features: []decoder.Feature{decoder.FeatureWiFi, decoder.FeatureMoving, decoder.FeatureTimestamp}, + }, nil + case Port212Version2: + return common.PayloadConfig{ + Fields: []common.FieldConfig{ + {Name: "Timestamp", Start: 0, Length: 4, Transform: timestamp}, + {Name: "Version", Start: 4, Length: 1}, + {Name: "Moving", Start: 4, Length: 1, Transform: alwaysFalse}, + {Name: "Rssi1", Start: 5, Length: 1}, + {Name: "Mac1", Start: 6, Length: 6, Hex: true}, + {Name: "Rssi2", Start: 12, Length: 1, Optional: true}, + {Name: "Mac2", Start: 13, Length: 6, Optional: true, Hex: true}, + {Name: "Rssi3", Start: 19, Length: 1, Optional: true}, + {Name: "Mac3", Start: 20, Length: 6, Optional: true, Hex: true}, + {Name: "Rssi4", Start: 26, Length: 1, Optional: true}, + {Name: "Mac4", Start: 27, Length: 6, Optional: true, Hex: true}, + {Name: "Rssi5", Start: 33, Length: 1, Optional: true}, + {Name: "Mac5", Start: 34, Length: 6, Optional: true, Hex: true}, + }, + TargetType: reflect.TypeOf(Port212Payload{}), + Features: []decoder.Feature{decoder.FeatureWiFi, decoder.FeatureMoving, decoder.FeatureTimestamp}, + }, nil + default: + return common.PayloadConfig{}, fmt.Errorf("%w: version %v for port %d not supported", common.ErrPortNotSupported, version, port) + } + case 213: + if len(payload) < Port213HeaderLength { + return common.PayloadConfig{}, common.ErrPayloadTooShort + } + var version = payload[Port213VersionIndex] + switch version { + case Port213Version1: + return common.PayloadConfig{ + Fields: []common.FieldConfig{ + {Name: "Timestamp", Start: 0, Length: 4, Transform: timestamp}, + {Name: "Version", Start: 4, Length: 1}, + {Name: "Moving", Start: 4, Length: 1, Transform: alwaysTrue}, + {Name: "Mac1", Start: 5, Length: 6, Hex: true}, + {Name: "Mac2", Start: 11, Length: 6, Optional: true, Hex: true}, + {Name: "Mac3", Start: 17, Length: 6, Optional: true, Hex: true}, + {Name: "Mac4", Start: 23, Length: 6, Optional: true, Hex: true}, + {Name: "Mac5", Start: 29, Length: 6, Optional: true, Hex: true}, + }, + TargetType: reflect.TypeOf(Port213Payload{}), + Features: []decoder.Feature{decoder.FeatureWiFi, decoder.FeatureMoving, decoder.FeatureTimestamp}, + }, nil + case Port213Version2: + return common.PayloadConfig{ + Fields: []common.FieldConfig{ + {Name: "Timestamp", Start: 0, Length: 4, Transform: timestamp}, + {Name: "Version", Start: 4, Length: 1}, + {Name: "Moving", Start: 4, Length: 1, Transform: alwaysTrue}, + {Name: "Rssi1", Start: 5, Length: 1}, + {Name: "Mac1", Start: 6, Length: 6, Hex: true}, + {Name: "Rssi2", Start: 12, Length: 1, Optional: true}, + {Name: "Mac2", Start: 13, Length: 6, Optional: true, Hex: true}, + {Name: "Rssi3", Start: 19, Length: 1, Optional: true}, + {Name: "Mac3", Start: 20, Length: 6, Optional: true, Hex: true}, + {Name: "Rssi4", Start: 26, Length: 1, Optional: true}, + {Name: "Mac4", Start: 27, Length: 6, Optional: true, Hex: true}, + {Name: "Rssi5", Start: 33, Length: 1, Optional: true}, + {Name: "Mac5", Start: 34, Length: 6, Optional: true, Hex: true}, + }, + TargetType: reflect.TypeOf(Port213Payload{}), + Features: []decoder.Feature{decoder.FeatureWiFi, decoder.FeatureMoving, decoder.FeatureTimestamp}, + }, nil + default: + return common.PayloadConfig{}, fmt.Errorf("%w: version %v for port %d not supported", common.ErrPortNotSupported, version, port) + } } return common.PayloadConfig{}, fmt.Errorf("%w: port %v not supported", common.ErrPortNotSupported, port) } /* GNSS solver routing and semantics: -- Ports 192/193/194/195/199 are GNSS NAV grouping ports. When a v2 solver is configured, we prefer it. +- Ports 192/193/194/195/199/210/211 are GNSS NAV grouping ports. When a v2 solver is configured, we prefer it. - Movement semantics by port: - 192: steady (Moving=false) - 193: moving (Moving=true) - 194: steady (Moving=false), timestamped payload (first 4 bytes UNIX seconds) is stripped before solving - 195: moving (Moving=true), timestamped payload (first 4 bytes UNIX seconds) is stripped before solving - 199: unspecified; Moving and Timestamp left nil unless future protocol specifies otherwise + - 210: steady (Moving=false), timestamped payload (first 4 bytes UNIX seconds), rotation-triggered + - 211: moving (Moving=true), timestamped payload (first 4 bytes UNIX seconds), rotation-triggered - When no v2 solver is provided: - - Ports 194/195 are not supported (they require timestamp stripping and explicit options). + - Ports 194/195/210/211 are not supported (they require timestamp stripping and explicit options). - Ports 192/193/199 fall back to the legacy v1 solver for backward compatibility. */ func (t TagXLv1Decoder) Decode(ctx context.Context, data string, port uint8) (*decoder.DecodedUplink, error) { switch port { // GNSS NAV grouping ports now use the v2 solver when available. - case 192, 193, 194, 195, 199: + case 192, 193, 194, 195, 199, 210, 211: if t.v2Solver != nil { devEui, _ := ctx.Value(decoder.DEVEUI_CONTEXT_KEY).(string) fcnt, _ := ctx.Value(decoder.FCNT_CONTEXT_KEY).(int) var movingPtr *bool switch port { - case 192, 194: + case 192, 194, 210: mv := false movingPtr = &mv - case 193, 195: + case 193, 195, 211: mv := true movingPtr = &mv default: @@ -407,8 +497,8 @@ func (t TagXLv1Decoder) Decode(ctx context.Context, data string, port uint8) (*d var tsPtr *time.Time payloadForSolve := data - // For timestamped GNSS ports (194, 195), strip the leading 4-byte timestamp (big-endian) - if port == 194 || port == 195 { + // For timestamped GNSS ports (194, 195, 210, 211), strip the leading 4-byte timestamp (big-endian) + if port == 194 || port == 195 || port == 210 || port == 211 { bytes, err := common.HexStringToBytes(data) if err != nil { return nil, err @@ -452,8 +542,8 @@ func (t TagXLv1Decoder) Decode(ctx context.Context, data string, port uint8) (*d } // Fallback to legacy v1 solver when v2 is not provided (keeps backward compatibility). - // Note: legacy path does not support 194/195 since v1 solver expects header as first byte. - if port == 194 || port == 195 { + // Note: legacy path does not support 194/195/210/211 since v1 solver expects header as first byte. + if port == 194 || port == 195 || port == 210 || port == 211 { return nil, fmt.Errorf("%w: port %v not supported without v2 solver", common.ErrPortNotSupported, port) } uplink, err := t.solver.Solve(ctx, data) diff --git a/pkg/decoder/tagxl/v1/decoder_test.go b/pkg/decoder/tagxl/v1/decoder_test.go index e659531..910f938 100644 --- a/pkg/decoder/tagxl/v1/decoder_test.go +++ b/pkg/decoder/tagxl/v1/decoder_test.go @@ -399,6 +399,28 @@ func TestDecode(t *testing.T) { payload: "68bad3c58aab4581b9e73a0eb580da120d7f85a75e770c6acad3dc2acdacbdcd576ab8147f5902557379b18d0f676a35fb9a6ae5ee03", expected: &exampleResponse, }, + // Port 210: GNSS, steady, timestamped, rotation-triggered (same format as 194) + { + port: 210, + payload: "68b9b2318f2b157de4733aa4d27b5d3b3c6ecc9460a20a196b754655c98607", + expected: &exampleResponse, + }, + { + port: 210, + payload: "68bad32509ab91418ae63a10b5004a0a3fef037ab2f06ce8e510820c1a0bdcecb49e1543fdd2f28f1c", + expected: &exampleResponse, + }, + // Port 211: GNSS, moving, timestamped, rotation-triggered (same format as 195) + { + port: 211, + payload: "68bad3c50aabd56cb2e7ba0db5805a5ac9d4edd8de8a021b4ae2b78e8c0b8391566ab8d47d1d4c55ae794a2c2da7a637b49d32e44800", + expected: &exampleResponse, + }, + { + port: 211, + payload: "68bad3c58aab4581b9e73a0eb580da120d7f85a75e770c6acad3dc2acdacbdcd576ab8147f5902557379b18d0f676a35fb9a6ae5ee03", + expected: &exampleResponse, + }, { port: 197, payload: "ff", @@ -1000,6 +1022,102 @@ func TestDecode(t *testing.T) { Version: Port201Version2, }, }, + // Port 212: WiFi, non-moving, timestamped, rotation-triggered (same format as 200, no Buffered) + { + port: 212, + payload: "68b9ac21ff", + expected: Port212Payload{}, + expectedErr: "port not supported: version 255 for port 212 not supported", + }, + { + port: 212, + payload: "68b9ac21003385f8ee30c2", + expected: Port212Payload{ + Timestamp: time.Date(2025, 9, 4, 15, 11, 29, 0, time.UTC), + Rssi1: nil, + Mac1: "3385f8ee30c2", + Moving: false, + Version: Port212Version1, + }, + }, + { + port: 212, + payload: "68b9ac2101d63385f8ee30c2", + expected: Port212Payload{ + Timestamp: time.Date(2025, 9, 4, 15, 11, 29, 0, time.UTC), + Rssi1: helpers.Int8Ptr(-42), + Mac1: "3385f8ee30c2", + Moving: false, + Version: Port212Version2, + }, + }, + { + port: 212, + payload: "68b9ac2101b7218f6c166fadb359ea3bdec77daff72faac81784ab263386a455d3a73592a063900b", + expected: Port212Payload{ + Timestamp: time.Date(2025, 9, 4, 15, 11, 29, 0, time.UTC), + Rssi1: helpers.Int8Ptr(-73), + Mac1: "218f6c166fad", + Rssi2: helpers.Int8Ptr(-77), + Mac2: helpers.StringPtr("59ea3bdec77d"), + Rssi3: helpers.Int8Ptr(-81), + Mac3: helpers.StringPtr("f72faac81784"), + Rssi4: helpers.Int8Ptr(-85), + Mac4: helpers.StringPtr("263386a455d3"), + Rssi5: helpers.Int8Ptr(-89), + Mac5: helpers.StringPtr("3592a063900b"), + Moving: false, + Version: Port212Version2, + }, + }, + // Port 213: WiFi, moving, timestamped, rotation-triggered (same format as 201, no Buffered) + { + port: 213, + payload: "68b9ac21ff", + expected: Port213Payload{}, + expectedErr: "port not supported: version 255 for port 213 not supported", + }, + { + port: 213, + payload: "68b9ac21003385f8ee30c2", + expected: Port213Payload{ + Timestamp: time.Date(2025, 9, 4, 15, 11, 29, 0, time.UTC), + Rssi1: nil, + Mac1: "3385f8ee30c2", + Moving: true, + Version: Port213Version1, + }, + }, + { + port: 213, + payload: "68b9ac2101d63385f8ee30c2", + expected: Port213Payload{ + Timestamp: time.Date(2025, 9, 4, 15, 11, 29, 0, time.UTC), + Rssi1: helpers.Int8Ptr(-42), + Mac1: "3385f8ee30c2", + Moving: true, + Version: Port213Version2, + }, + }, + { + port: 213, + payload: "68bae3ab01d3f0b0140c96bbc7e4c32a622ea4c5e0286d8a9478b4e0286d8aabfcada86e84e1a812", + expected: Port213Payload{ + Timestamp: time.Date(2025, 9, 5, 13, 20, 43, 0, time.UTC), + Mac1: "f0b0140c96bb", + Rssi1: helpers.Int8Ptr(-45), + Mac2: helpers.StringPtr("e4c32a622ea4"), + Rssi2: helpers.Int8Ptr(-57), + Mac3: helpers.StringPtr("e0286d8a9478"), + Rssi3: helpers.Int8Ptr(-59), + Mac4: helpers.StringPtr("e0286d8aabfc"), + Rssi4: helpers.Int8Ptr(-76), + Mac5: helpers.StringPtr("a86e84e1a812"), + Rssi5: helpers.Int8Ptr(-83), + Moving: true, + Version: Port213Version2, + }, + }, } if logger.Logger == nil { @@ -1010,11 +1128,11 @@ func TestDecode(t *testing.T) { ctx := context.WithValue(context.Background(), decoder.DEVEUI_CONTEXT_KEY, test.devEui) ctx = context.WithValue(ctx, decoder.FCNT_CONTEXT_KEY, 1) - // Use SolverV2 for GNSS ports (192/193/194/195/199) so timestamped ports work without error and provide expected data. + // Use SolverV2 for GNSS ports (192/194/195/199/210/211) so timestamped ports work without error and provide expected data. expectedAny := test.expected opts := []Option{} switch test.port { - case 192, 194, 195: + case 192, 194, 195, 210, 211: // For GNSS ports, use SolverV2 and return the same structure as port 192 expectation // so that tests compare against exampleResponse. For 194/195, timestamp is handled by decoder. v2Data := &exampleResponse @@ -1252,6 +1370,22 @@ func TestFeatures(t *testing.T) { payload: "68b9ac2101b7218f6c166fadb359ea3bdec77daff72faac81784ab263386a455d3a73592a063900b", port: 201, }, + { + payload: "68b9ac2100218f6c166fad59ea3bdec77df72faac81784263386a455d33592a063900b", + port: 212, + }, + { + payload: "68b9ac2101b7218f6c166fadb359ea3bdec77daff72faac81784ab263386a455d3a73592a063900b", + port: 212, + }, + { + payload: "68b9ac2100218f6c166fad59ea3bdec77df72faac81784263386a455d33592a063900b", + port: 213, + }, + { + payload: "68b9ac2101b7218f6c166fadb359ea3bdec77daff72faac81784ab263386a455d3a73592a063900b", + port: 213, + }, } mux := http.NewServeMux() diff --git a/pkg/decoder/tagxl/v1/examples_test.go b/pkg/decoder/tagxl/v1/examples_test.go index e018ad2..ea25b6f 100644 --- a/pkg/decoder/tagxl/v1/examples_test.go +++ b/pkg/decoder/tagxl/v1/examples_test.go @@ -201,6 +201,176 @@ func TestTagXL_GNSS_Port193_Moving_NoTimestamp(t *testing.T) { } } +// GNSS timestamped, not moving, rotation-triggered (port 210) — same format as 194 +func TestTagXL_GNSS_Port210_Timestamped_RotationTriggered(t *testing.T) { + if logger.Logger == nil { + logger.NewLogger() + } + log := logger.Logger + devEui := "0011223344556677" + fcnt := 42 + + // Timestamp 0x68BAD325 => 1757074213 + secs := uint32(1757074213) + ts := time.Unix(int64(secs), 0).UTC() + + payloads := []string{ + "68bad32509ab91418ae63a10b5004a0a3fef037ab2f06ce8e510820c1a0bdcecb49e1543fdd2f28f1c", + "68bad32589b379e7ba0fb5006b9aaa8c8e25febf16f4e5c31d0cc8ca12a1cffdddf16c2cf82877f1edee4ecbc5ef54", + } + for _, payload := range payloads { + cap := &captureSolverV2{ + resp: decoder.NewDecodedUplink( + []decoder.Feature{decoder.FeatureGNSS, decoder.FeatureTimestamp}, + &fakeGNSSData{lat: 47.0, lon: 8.0, alt: 10.0, ts: &ts}, + ), + } + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, log, WithSolverV2(cap)) + + ctx := context.WithValue(context.Background(), decoder.DEVEUI_CONTEXT_KEY, devEui) + ctx = context.WithValue(ctx, decoder.FCNT_CONTEXT_KEY, fcnt) + + out, err := dec.Decode(ctx, payload, 210) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedForwarded := payload[8:] // strip 4B timestamp (8 hex chars) + if cap.lastPayload != expectedForwarded { + t.Fatalf("expected forwarded payload %q, got %q", expectedForwarded, cap.lastPayload) + } + if cap.lastOptions.Port != 192 { + t.Fatalf("expected port 192, got %d", cap.lastOptions.Port) + } + if cap.lastOptions.Moving == nil || *cap.lastOptions.Moving != false { + t.Fatalf("expected Moving=false for port 210, got %+v", cap.lastOptions.Moving) + } + if !out.Is(decoder.FeatureGNSS) || !out.Is(decoder.FeatureTimestamp) { + t.Fatalf("expected GNSS and Timestamp features in result") + } + } +} + +// GNSS timestamped, moving, rotation-triggered (port 211) — same format as 195 +func TestTagXL_GNSS_Port211_Timestamped_RotationTriggered(t *testing.T) { + if logger.Logger == nil { + logger.NewLogger() + } + log := logger.Logger + devEui := "0011223344556677" + fcnt := 43 + + // Timestamp 0x68BAD3C5 => 1757074373 + secs := uint32(1757074373) + ts := time.Unix(int64(secs), 0).UTC() + + payloads := []string{ + "68bad3c50aabd56cb2e7ba0db5805a5ac9d4edd8de8a021b4ae2b78e8c0b8391566ab8d47d1d4c55ae794a2c2da7a637b49d32e44800", + "68bad3c58aab4581b9e73a0eb580da120d7f85a75e770c6acad3dc2acdacbdcd576ab8147f5902557379b18d0f676a35fb9a6ae5ee03", + } + for _, payload := range payloads { + cap := &captureSolverV2{ + resp: decoder.NewDecodedUplink( + []decoder.Feature{decoder.FeatureGNSS, decoder.FeatureTimestamp}, + &fakeGNSSData{lat: 47.0, lon: 8.0, alt: 10.0, ts: &ts}, + ), + } + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, log, WithSolverV2(cap)) + + ctx := context.WithValue(context.Background(), decoder.DEVEUI_CONTEXT_KEY, devEui) + ctx = context.WithValue(ctx, decoder.FCNT_CONTEXT_KEY, fcnt) + + out, err := dec.Decode(ctx, payload, 211) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedForwarded := payload[8:] // strip 4B timestamp (8 hex chars) + if cap.lastPayload != expectedForwarded { + t.Fatalf("expected forwarded payload %q, got %q", expectedForwarded, cap.lastPayload) + } + if cap.lastOptions.Port != 192 { + t.Fatalf("expected port 192, got %d", cap.lastOptions.Port) + } + if cap.lastOptions.Moving == nil || *cap.lastOptions.Moving != true { + t.Fatalf("expected Moving=true for port 211, got %+v", cap.lastOptions.Moving) + } + if !out.Is(decoder.FeatureGNSS) || !out.Is(decoder.FeatureTimestamp) { + t.Fatalf("expected GNSS and Timestamp features in result") + } + } +} + +// WiFi non-moving, timestamped, rotation-triggered (port 212) — same format as 200 but no Buffered +func TestTagXL_WiFi_Port212_Timestamped_RotationTriggered(t *testing.T) { + if logger.Logger == nil { + logger.NewLogger() + } + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, logger.Logger) + // 0x68BAE3AB timestamp (1757078443) + v2 + [RSSI,MAC] tuples + payload := "68bae3ab01d3f0b0140c96bbc7e4c32a622ea4c5e0286d8a9478b4e0286d8aabfcada86e84e1a812" + + out, err := dec.Decode(context.TODO(), payload, 212) + if err != nil { + t.Fatalf("decode error: %v", err) + } + // Features: WiFi + Timestamp + Moving, but NOT Buffered + if !out.Is(decoder.FeatureWiFi) || !out.Is(decoder.FeatureTimestamp) || !out.Is(decoder.FeatureMoving) { + t.Fatalf("expected WiFi + Timestamp + Moving features") + } + if out.Is(decoder.FeatureBuffered) { + t.Fatalf("port 212 should NOT have Buffered feature") + } + + ts := out.Data.(decoder.UplinkFeatureTimestamp).GetTimestamp() + if ts == nil || ts.Unix() != 1757078443 { + t.Fatalf("expected timestamp 1757078443, got %v", ts) + } + wifi := out.Data.(decoder.UplinkFeatureWiFi) + aps := wifi.GetAccessPoints() + if len(aps) != 5 { + t.Fatalf("expected 5 APs, got %d", len(aps)) + } + moving := out.Data.(decoder.UplinkFeatureMoving) + if moving.IsMoving() { + t.Fatalf("expected IsMoving=false for port 212") + } +} + +// WiFi moving, timestamped, rotation-triggered (port 213) — same format as 201 but no Buffered +func TestTagXL_WiFi_Port213_Timestamped_RotationTriggered(t *testing.T) { + if logger.Logger == nil { + logger.NewLogger() + } + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, logger.Logger) + // 0x68BAE3AB timestamp (1757078443) + v2 + [RSSI,MAC] tuples + payload := "68bae3ab01d3f0b0140c96bbc7e4c32a622ea4c5e0286d8a9478b4e0286d8aabfcada86e84e1a812" + + out, err := dec.Decode(context.TODO(), payload, 213) + if err != nil { + t.Fatalf("decode error: %v", err) + } + // Features: WiFi + Timestamp + Moving, but NOT Buffered + if !out.Is(decoder.FeatureWiFi) || !out.Is(decoder.FeatureTimestamp) || !out.Is(decoder.FeatureMoving) { + t.Fatalf("expected WiFi + Timestamp + Moving features") + } + if out.Is(decoder.FeatureBuffered) { + t.Fatalf("port 213 should NOT have Buffered feature") + } + + ts := out.Data.(decoder.UplinkFeatureTimestamp).GetTimestamp() + if ts == nil || ts.Unix() != 1757078443 { + t.Fatalf("expected timestamp 1757078443, got %v", ts) + } + wifi := out.Data.(decoder.UplinkFeatureWiFi) + aps := wifi.GetAccessPoints() + if len(aps) != 5 { + t.Fatalf("expected 5 APs, got %d", len(aps)) + } + moving := out.Data.(decoder.UplinkFeatureMoving) + if !moving.IsMoving() { + t.Fatalf("expected IsMoving=true for port 213") + } +} + // GNSS timestamped, not moving, two-frame NAV (port 194) from logs func TestTagXL_GNSS_Port194_Timestamped(t *testing.T) { if logger.Logger == nil { diff --git a/pkg/decoder/tagxl/v1/gnss_v2_test.go b/pkg/decoder/tagxl/v1/gnss_v2_test.go index 39595ba..c0ae895 100644 --- a/pkg/decoder/tagxl/v1/gnss_v2_test.go +++ b/pkg/decoder/tagxl/v1/gnss_v2_test.go @@ -116,6 +116,84 @@ func TestGNSS_SolverV2_192_193_NoTimestamp(t *testing.T) { } } +func TestGNSS_SolverV2_210_211_TimestampStrippedAndPassed(t *testing.T) { + log := newLogger() + + // Build a payload with 4B timestamp prefix + header 0x80 after + secs := uint32(1750000000) // some fixed time + ts := time.Unix(int64(secs), 0).UTC() + tsBytes := make([]byte, 4) + // big-endian + tsBytes[0] = byte((secs >> 24) & 0xff) + tsBytes[1] = byte((secs >> 16) & 0xff) + tsBytes[2] = byte((secs >> 8) & 0xff) + tsBytes[3] = byte(secs & 0xff) + + tsHex := hex.EncodeToString(tsBytes) + headerHex := "80" + payloadWithTS := tsHex + headerHex + "abcd" // ts + GHDR + rest + + devEui := "0011223344556677" + fcnt := 321 + + // Response includes GNSS + Timestamp feature + resp := decoder.NewDecodedUplink([]decoder.Feature{decoder.FeatureGNSS, decoder.FeatureTimestamp}, &fakeGNSSData{ + lat: 47.1, lon: 8.1, alt: 12.0, ts: &ts, + }) + + cap := &captureSolverV2{resp: resp} + + dec := NewTagXLv1Decoder(context.TODO(), solver.MockSolverV1{}, log, + WithSolverV2(cap), + ) + + ctx := context.WithValue(context.Background(), decoder.DEVEUI_CONTEXT_KEY, devEui) + ctx = context.WithValue(ctx, decoder.FCNT_CONTEXT_KEY, fcnt) + + // Port 210 -> steady, timestamp present and should be stripped before solve + out, err := dec.Decode(ctx, payloadWithTS, 210) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedForwarded := headerHex + "abcd" + if cap.lastPayload != expectedForwarded { + t.Fatalf("expected forwarded payload %q, got %q", expectedForwarded, cap.lastPayload) + } + if cap.lastOptions.Port != 192 { + t.Fatalf("expected port 192, got %d", cap.lastOptions.Port) + } + if cap.lastOptions.Moving == nil || *cap.lastOptions.Moving != false { + t.Fatalf("expected Moving=false for port 210, got %+v", cap.lastOptions.Moving) + } + if cap.lastOptions.Timestamp == nil || !cap.lastOptions.Timestamp.Equal(ts) { + t.Fatalf("expected Timestamp=%v for port 210, got %+v", ts, cap.lastOptions.Timestamp) + } + if !out.Is(decoder.FeatureGNSS) || !out.Is(decoder.FeatureTimestamp) { + t.Fatalf("expected GNSS and Timestamp features in result") + } + + // Port 211 -> moving, timestamp present and should be stripped before solve + out, err = dec.Decode(ctx, payloadWithTS, 211) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cap.lastPayload != expectedForwarded { + t.Fatalf("expected forwarded payload %q, got %q", expectedForwarded, cap.lastPayload) + } + if cap.lastOptions.Port != 192 { + t.Fatalf("expected port 192, got %d", cap.lastOptions.Port) + } + if cap.lastOptions.Moving == nil || *cap.lastOptions.Moving != true { + t.Fatalf("expected Moving=true for port 211, got %+v", cap.lastOptions.Moving) + } + if cap.lastOptions.Timestamp == nil || !cap.lastOptions.Timestamp.Equal(ts) { + t.Fatalf("expected Timestamp=%v for port 211, got %+v", ts, cap.lastOptions.Timestamp) + } + if !out.Is(decoder.FeatureGNSS) || !out.Is(decoder.FeatureTimestamp) { + t.Fatalf("expected GNSS and Timestamp features in result") + } +} + func TestGNSS_SolverV2_194_195_TimestampStrippedAndPassed(t *testing.T) { log := newLogger() diff --git a/pkg/decoder/tagxl/v1/port212.go b/pkg/decoder/tagxl/v1/port212.go new file mode 100644 index 0000000..93eac96 --- /dev/null +++ b/pkg/decoder/tagxl/v1/port212.go @@ -0,0 +1,118 @@ +package tagxl + +import ( + "time" + + "github.com/truvami/decoder/pkg/decoder" +) + +// Port 212: WiFi localization triggered by rotation state change (non-moving). +// Same binary format as port 200 (timestamped WiFi) but rotation-triggered, not buffered. +// +// Version v1 (without RSSI values): +// +------+------+-----------------------------------------------+------------+ +// | Byte | Size | Description | Format | +// +------+------+-----------------------------------------------+------------+ +// | 0 | 4 | timestamp (Unix epoch seconds) | uint32 | +// | 4 | 1 | version (0x00 = v1, 0x01 = v2) | byte | +// | 5 | 6 | mac address signal 1 | byte[6] | +// | 11 | 6 | mac address signal 2 | byte[6] | +// | 17 | 6 | mac address signal 3 | byte[6] | +// | 23 | 6 | mac address signal 4 | byte[6] | +// | 29 | 6 | mac address signal 5 | byte[6] | +// +------+------+-----------------------------------------------+------------+ +// +// Version v2 (with RSSI values): +// +------+------+-----------------------------------------------+------------+ +// | Byte | Size | Description | Format | +// +------+------+-----------------------------------------------+------------+ +// | 0 | 4 | timestamp (Unix epoch seconds) | uint32 | +// | 4 | 1 | version (0x00 = v1, 0x01 = v2) | byte | +// | 5 | 1 | rssi signal 1 | int8 | +// | 6 | 6 | mac address signal 1 | byte[6] | +// | 12 | 1 | rssi signal 2 | int8 | +// | 13 | 6 | mac address signal 2 | byte[6] | +// | 19 | 1 | rssi signal 3 | int8 | +// | 20 | 6 | mac address signal 3 | byte[6] | +// | 26 | 1 | rssi signal 4 | int8 | +// | 27 | 6 | mac address signal 4 | byte[6] | +// | 33 | 1 | rssi signal 5 | int8 | +// | 34 | 6 | mac address signal 5 | byte[6] | +// +------+------+-----------------------------------------------+------------+ + +const ( + Port212HeaderLength = 5 // minimum payload bytes (4B timestamp + 1B version) + Port212VersionIndex = 4 // byte offset of the version field + Port212Version1 byte = 0x00 + Port212Version2 byte = 0x01 +) + +type Port212Payload struct { + Timestamp time.Time `json:"timestamp"` + Version byte `json:"version" validate:"gte=0,lte=1"` + Moving bool `json:"moving"` // Always false for Port 212 + Rssi1 *int8 `json:"rssi1" validate:"gte=-120,lte=-20"` + Mac1 string `json:"mac1"` + Rssi2 *int8 `json:"rssi2" validate:"gte=-120,lte=-20"` + Mac2 *string `json:"mac2"` + Rssi3 *int8 `json:"rssi3" validate:"gte=-120,lte=-20"` + Mac3 *string `json:"mac3"` + Rssi4 *int8 `json:"rssi4" validate:"gte=-120,lte=-20"` + Mac4 *string `json:"mac4"` + Rssi5 *int8 `json:"rssi5" validate:"gte=-120,lte=-20"` + Mac5 *string `json:"mac5"` +} + +var _ decoder.UplinkFeatureWiFi = &Port212Payload{} +var _ decoder.UplinkFeatureMoving = &Port212Payload{} +var _ decoder.UplinkFeatureTimestamp = &Port212Payload{} + +func (p Port212Payload) GetTimestamp() *time.Time { + return &p.Timestamp +} + +func (p Port212Payload) GetAccessPoints() []decoder.AccessPoint { + accessPoints := []decoder.AccessPoint{} + + if p.Mac1 != "" { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: p.Mac1, + RSSI: p.Rssi1, + }) + } + + if p.Mac2 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac2, + RSSI: p.Rssi2, + }) + } + + if p.Mac3 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac3, + RSSI: p.Rssi3, + }) + } + + if p.Mac4 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac4, + RSSI: p.Rssi4, + }) + } + + if p.Mac5 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac5, + RSSI: p.Rssi5, + }) + } + + return accessPoints +} + +// Port 212 is the non-moving (steady) rotation-triggered variant. +func (p Port212Payload) IsMoving() bool { + return false +} diff --git a/pkg/decoder/tagxl/v1/port213.go b/pkg/decoder/tagxl/v1/port213.go new file mode 100644 index 0000000..2caeb79 --- /dev/null +++ b/pkg/decoder/tagxl/v1/port213.go @@ -0,0 +1,118 @@ +package tagxl + +import ( + "time" + + "github.com/truvami/decoder/pkg/decoder" +) + +// Port 213: WiFi localization triggered by rotation state change (moving). +// Same binary format as port 201 (timestamped WiFi) but rotation-triggered, not buffered. +// +// Version v1 (without RSSI values): +// +------+------+-----------------------------------------------+------------+ +// | Byte | Size | Description | Format | +// +------+------+-----------------------------------------------+------------+ +// | 0 | 4 | timestamp (Unix epoch seconds) | uint32 | +// | 4 | 1 | version (0x00 = v1, 0x01 = v2) | byte | +// | 5 | 6 | mac address signal 1 | byte[6] | +// | 11 | 6 | mac address signal 2 | byte[6] | +// | 17 | 6 | mac address signal 3 | byte[6] | +// | 23 | 6 | mac address signal 4 | byte[6] | +// | 29 | 6 | mac address signal 5 | byte[6] | +// +------+------+-----------------------------------------------+------------+ +// +// Version v2 (with RSSI values): +// +------+------+-----------------------------------------------+------------+ +// | Byte | Size | Description | Format | +// +------+------+-----------------------------------------------+------------+ +// | 0 | 4 | timestamp (Unix epoch seconds) | uint32 | +// | 4 | 1 | version (0x00 = v1, 0x01 = v2) | byte | +// | 5 | 1 | rssi signal 1 | int8 | +// | 6 | 6 | mac address signal 1 | byte[6] | +// | 12 | 1 | rssi signal 2 | int8 | +// | 13 | 6 | mac address signal 2 | byte[6] | +// | 19 | 1 | rssi signal 3 | int8 | +// | 20 | 6 | mac address signal 3 | byte[6] | +// | 26 | 1 | rssi signal 4 | int8 | +// | 27 | 6 | mac address signal 4 | byte[6] | +// | 33 | 1 | rssi signal 5 | int8 | +// | 34 | 6 | mac address signal 5 | byte[6] | +// +------+------+-----------------------------------------------+------------+ + +const ( + Port213HeaderLength = 5 // minimum payload bytes (4B timestamp + 1B version) + Port213VersionIndex = 4 // byte offset of the version field + Port213Version1 byte = 0x00 + Port213Version2 byte = 0x01 +) + +type Port213Payload struct { + Timestamp time.Time `json:"timestamp"` + Version byte `json:"version" validate:"gte=0,lte=1"` + Moving bool `json:"moving"` // Always true for Port 213 + Rssi1 *int8 `json:"rssi1" validate:"gte=-120,lte=-20"` + Mac1 string `json:"mac1"` + Rssi2 *int8 `json:"rssi2" validate:"gte=-120,lte=-20"` + Mac2 *string `json:"mac2"` + Rssi3 *int8 `json:"rssi3" validate:"gte=-120,lte=-20"` + Mac3 *string `json:"mac3"` + Rssi4 *int8 `json:"rssi4" validate:"gte=-120,lte=-20"` + Mac4 *string `json:"mac4"` + Rssi5 *int8 `json:"rssi5" validate:"gte=-120,lte=-20"` + Mac5 *string `json:"mac5"` +} + +var _ decoder.UplinkFeatureWiFi = &Port213Payload{} +var _ decoder.UplinkFeatureMoving = &Port213Payload{} +var _ decoder.UplinkFeatureTimestamp = &Port213Payload{} + +func (p Port213Payload) GetTimestamp() *time.Time { + return &p.Timestamp +} + +func (p Port213Payload) GetAccessPoints() []decoder.AccessPoint { + accessPoints := []decoder.AccessPoint{} + + if p.Mac1 != "" { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: p.Mac1, + RSSI: p.Rssi1, + }) + } + + if p.Mac2 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac2, + RSSI: p.Rssi2, + }) + } + + if p.Mac3 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac3, + RSSI: p.Rssi3, + }) + } + + if p.Mac4 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac4, + RSSI: p.Rssi4, + }) + } + + if p.Mac5 != nil { + accessPoints = append(accessPoints, decoder.AccessPoint{ + MAC: *p.Mac5, + RSSI: p.Rssi5, + }) + } + + return accessPoints +} + +// Port 213 is the moving rotation-triggered variant. +func (p Port213Payload) IsMoving() bool { + return true +}