Decoder: add support for new ts2 (Smart Label v2) payload tags
Summary
Our payload decoder currently supports only the tags defined in the tagxl-v2 firmware (apps/trackit/settings.h). The ts2 / Smart Label v2 firmware (ts2/app/tracker/lorawan_proto.h) introduces a number of new TLV tags and uplink message types that the decoder does not yet handle. This issue asks the backend team to extend the decoder to cover them.
Tier 1 (do first): WiFi AP constraint (Min_AP), motion detection plan v2, battery temperature, and buffer level. Tier 2 (later): everything else — scan timers/quick scans, BLE config, batch count, battery level thresholds, reset count/cause, scan count, and the new localization uplinks. All are specified below.
Source of truth: the firmware code only. Everything in this issue is derived from ts2/app/tracker/lorawan_proto.h, lorawan_proto.c, and the handlers in logic.c. The repo also contains a ts2/Payload format.md draft — do not rely on it. It contradicts the compiled code in several places (e.g. it describes a 0x10/0x14/0x18/0x1C uplink "tag" scheme that does not exist in code, a multi-byte WiFi-AP filter that is actually a single byte, and BLE-compact ports 190/191/200/201 that the code does not implement). Decode against the code; confirm anything ambiguous with the firmware team.
Priority & sequencing
Tier 1 — implement first
| Setting |
Set tag |
Get tag |
Size |
Format |
One-liner |
| WiFi AP constraint (Min_AP) |
0x2A |
0x4D |
1 |
uint8 |
Minimum WiFi-AP count; if a scan finds ≥ this many APs the device skips the GNSS/GPS scan. |
| Motion detection plan v2 |
0x30 |
0x60 |
7 |
struct |
Slice-based movement-detection plan (replaces the simple accel threshold + delay). |
| Battery temperature |
0x31 |
0x61 |
1 |
int8 °C |
Battery temperature reading, single signed byte, no scaling. |
| Buffer level |
— |
0x63 |
2 |
uint16 BE |
Number of stored unconfirmed messages (0–65535). |
Full byte-level layouts for all four are in the TLV tag specifications section below.
Tier 2 — later (everything else)
| Setting |
Set tag |
Get tag |
Size |
Format |
One-liner |
| Scan timers (quick scans) |
0x32 |
0x62 |
3–5 |
struct |
Per-timer scan config; adds QuickMoving / QuickSteady "quick scan" types. |
| BLE config |
0x2C |
0x51 |
16 |
struct |
BLE scan/advert/filter configuration. |
| Batch count |
0x2B |
0x50 |
1 |
uint8 |
Messages per batch. |
| Battery level thresholds |
0x29 |
0x4C |
2 |
struct |
Critical + low battery percentages. |
| Reset count |
— |
0x49 |
2 |
uint16 BE |
Number of resets (read-only). |
| Reset cause |
— |
0x4A |
4 |
uint32 BE |
Reset-cause code (read-only). |
| Scan count |
— |
0x4B |
4 |
struct |
GNSS + WiFi scan counts (read-only). |
| New localization uplinks |
— |
— |
var |
by fPort |
BLE (160–163), WiFi-ts (200/201), GNSS-NA (192–195), GPS-SA (10). |
Full byte-level layouts are in the TLV tag specifications and uplink sections below.
TLV tag specifications
All of the following are defined in lorawan_proto_tlv_tag_t (ts2/app/tracker/lorawan_proto.h) and are not in the tagxl-v2 decoder set.
Settings envelope (applies to every tag below). These TLVs travel inside the settings message (uplink and downlink both on port 151 — kLoraWanProtoUlSettings / kLoraWanProtoDlSettings). The payload is a 3-byte header lorawan_proto_settings_header_t = { tag=0x4C, len, count } followed by count TLVs, each [tag][len][value…]. The header tag 0x4C is verified in proto_decode_settings (kLoraWanTagSettings). All multi-byte integers are big-endian on the wire — firmware byte-swaps every uint16/uint32 with utils_rev16/utils_rev32 on both encode and decode. Sizes below are the value length only (excludes the 2-byte tag+len).
Each entry verified against the proto_decode/proto_encode handlers in ts2/app/tracker/logic.c.
Motion detection plan v2 — Set 0x30, Get 0x60 (Tier 1)
Size 7 bytes, lorawan_proto_settings_moving_v2_t. Slice-based movement detection; replaces tagxl's simple accel_sensitivity + accel_delay. Set requires exactly 7 bytes.
| Offset |
Bytes |
Field |
Type |
Notes |
| 0 |
2 |
acc_thresh_mg |
uint16 BE |
acceleration threshold, mG |
| 2 |
1 |
slice_time_seconds |
uint8 |
|
| 3 |
1 |
window_slice_count |
uint8 |
|
| 4 |
1 |
window_slice_required |
uint8 |
|
| 5 |
1 |
start_movement_window_count |
uint8 |
|
| 6 |
1 |
stop_movement_window_count |
uint8 |
|
Battery temperature — Set 0x31, Get 0x61 (Tier 1)
Size 1 byte, int8 degrees Celsius. In logic.c the value is cast from the driver's int32 down to a single signed byte (int8_t tmpr = (int8_t) logic.bat.read.temperature), no scaling. Range −128…127 °C; one byte so no endianness concern.
Buffer level — Get 0x63 only (Tier 1)
Size 2 bytes, uint16 BE = number of stored unconfirmed messages (storage.record_count()). Only emitted when the device has the storage capability. No Set handler exists (0x33 is reserved in the enum but not implemented).
WiFi AP constraint / Min_AP — Set 0x2A, Get 0x4D (Tier 1)
Size 1 byte, uint8 (wifi_ap_constr). Set requires exactly 1 byte. Semantics (logic.c:2482–2497): it is the minimum number of WiFi APs that makes a WiFi fix "good enough" — when a scan finds wifi_scan_count >= wifi_ap_constr (and the value is non-zero), the device skips the GNSS/GPS scan for that cycle. A value of 0 disables the check. Note: the firmware implements this as a single count byte, not the multi-byte "Min RSSI + head/tail + MAC filter" structure described in the draft doc — decode as one byte.
Scan timers (quick scans) — Set 0x32, Get 0x62 (Tier 2 — later)
Deferred per product decision. Full spec retained below for when it is picked up.
Value is one lorawan_proto_settings_scan_timer_t = 5 bytes (so the full TLV is [0x32][len][value]), verified against the kLoraWanTlvTagScanTimersSet handler + update_localization_timers in logic.c.
typedef enum {
kLoraWanScanInstanceTypeUnkown = 0, // 0 invalid
kLoraWanScanInstanceTypeMoving, // 1
kLoraWanScanInstanceTypeSteady, // 2
kLoraWanScanInstanceTypeQuickMoving, // 3
kLoraWanScanInstanceTypeQuickSteady, // 4
} lorawan_proto_settings_timer_type;
typedef union __attribute__((packed)) { // lorawan_proto_loc_method_t (1 byte)
struct { uint8_t wifi:1, ble:1, gnss_na:1, gnss_sa:1; }; // bit0..bit3, bits4-7 unused
uint8_t any;
} lorawan_proto_loc_method_t;
| Offset |
Bytes |
Field |
Type |
Notes |
| 0 |
1 |
type |
uint8 |
timer instance type (enum above) |
| 1 |
2 |
period |
uint16 BE |
scan interval, seconds |
| 3 |
1 |
method |
uint8 bitfield |
which scanners run: bit0 WiFi, bit1 BLE, bit2 GNSS-NA, bit3 GNSS-SA |
| 4 |
1 |
cont_op |
uint8 bitfield |
"continue on" scanners, same bit layout |
- One timer instance per TLV — several
0x32 TLVs (one per type) are sent to configure multiple timers.
- Partial TLVs accepted: the Set handler only requires
type + period (min len = 3) and copies MIN(5, len) bytes; method/cont_op may be omitted.
- Range checks: Moving/Steady
period 120–65535 s; Quick variants not range-checked.
- Get (
0x62) is a no-op in this revision (packs no response), so the decoder currently only sees 0x32 in the Set direction.
BLE config — Set 0x2C, Get 0x51
Size 16 bytes, lorawan_proto_ble_config_t. Set requires exactly 16 bytes.
| Offset |
Bytes |
Field |
Type |
Notes |
| 0 |
1 |
scan_seconds |
uint8 |
|
| 1 |
1 |
advert_minutes |
uint8 |
|
| 2 |
1 |
max_scan_result |
uint8 |
|
| 3 |
1 |
min_rssi |
int8 |
signed |
| 4 |
1 |
filter_type |
uint8 |
|
| 5 |
11 |
filter_data |
uint8[11] |
raw filter bytes |
Batch count — Set 0x2B, Get 0x50
Size 1 byte, uint8 (batch_size). Set requires exactly 1 byte.
Battery level thresholds — Set 0x29, Get 0x4C
Size 2 bytes, lorawan_proto_battery_levels_t. Set requires exactly 2 bytes.
| Offset |
Bytes |
Field |
Type |
Notes |
| 0 |
1 |
level_critical |
uint8 |
percent |
| 1 |
1 |
level_low |
uint8 |
percent |
Reset count — Get 0x49 only
Size 2 bytes, uint16 BE = number of resets. Read-only counter.
Reset cause — Get 0x4A only
Size 4 bytes, uint32 BE = reset-cause code (lorawan_proto_reset_cause_t, values 0–10: 0 Unknown, 1 PowerOn, 2 ResetPin, 3 DeepSleep, 4 WDT, 5 HardFault, 6 Software, 7 HighVoltage, 8 LowVoltage, 9 Debug, 10 DCDC). Read-only.
Scan count — Get 0x4B only
Size 4 bytes, lorawan_proto_settings_scan_count_t. Read-only counter.
| Offset |
Bytes |
Field |
Type |
Notes |
| 0 |
2 |
gnss_scan_count |
uint16 BE |
|
| 2 |
2 |
wifi_scan_count |
uint16 BE |
|
⚠️ Set-tag collisions in the enum — but Get tags are unambiguous. In lorawan_proto.h three Set values are reused (0x29 = ResetCount/BatteryLevel, 0x2A = ResetCause/WifiApConstr, 0x2B = ScanCount/BatchCount). The firmware resolves this by implementing the Set direction only for BatteryLevel / WifiApConstr / BatchCount; ResetCount, ResetCause and ScanCount are Get-only read-only counters. Every Get tag is distinct, so decoding uplink (Get) responses is unambiguous — the collision only affects downlink Set of 0x29/0x2A/0x2B. Confirm the final mapping with the firmware team.
New uplink message types / ports
ts2 introduces localization uplinks that the current decoder does not parse. The message type is selected by the LoRaWAN fPort, not by a payload tag byte. Ports are from the lorawan_proto_ul_port_t enum in lorawan_proto.h, and the port selection is confirmed in logic.c (moving vs. steady is logic.loc.moving ? …Moving… : …Steady…; the timestamped variant is used when a timestamp is available).
| fPort |
Message |
Timestamp |
Packer (lorawan_proto.c) |
| 160 |
BLE, steady |
no |
proto_pack_ble |
| 161 |
BLE, moving |
no |
proto_pack_ble |
| 162 |
BLE, steady |
yes |
proto_pack_ble_ts |
| 163 |
BLE, moving |
yes |
proto_pack_ble_ts |
| 192 |
GNSS-NA, steady |
no |
proto_pack_gnss |
| 193 |
GNSS-NA, moving |
no |
proto_pack_gnss |
| 194 |
GNSS-NA, steady |
yes |
proto_pack_gnss_ts |
| 195 |
GNSS-NA, moving |
yes |
proto_pack_gnss_ts |
| 197 |
WiFi, steady |
no |
proto_pack_wifi |
| 198 |
WiFi, moving |
no |
proto_pack_wifi |
| 200 |
WiFi, steady |
yes |
proto_pack_wifi_ts |
| 201 |
WiFi, moving |
yes |
proto_pack_wifi_ts |
| 10 |
GPS-SA (stand-alone fix) |
yes |
proto_pack_gps_ts |
| 151 |
Settings |
— |
(TLV envelope, see above) |
Note: the code assigns WiFi-timestamped to ports 200/201 — these are not BLE-compact messages (the draft doc is wrong on this). There is no compact WiFi/BLE format, no ports 190/191/181, and no sequence-numbered 0x80–0x8C tags anywhere in the code.
WiFi payload (proto_pack_wifi / proto_pack_wifi_ts)
// per-AP entry, 7 bytes:
struct { int8_t rssi; uint8_t addr[6]; } // lorawan_proto_ul_wifi_mac_t
// port 197/198 (no ts): [ tag(1) ] [ (rssi+mac) * n ]
// port 200/201 (with ts): [ ts(4) ] [ tag(1) ] [ (rssi+mac) * n ]
tag is a constant 0x01 (set in proto_pack_wifi/proto_pack_wifi_ts) — it is not a moving/steady/ts indicator; use the fPort for that.
- In the timestamped variant the 4-byte
ts comes before the tag byte (lorawan_proto_ul_wifi_ts_t), and ts is big-endian (utils_rev32).
- MAC bytes are copied verbatim (6 bytes);
rssi is signed.
BLE payload (proto_pack_ble / proto_pack_ble_ts)
Identical layout to WiFi: 7-byte { int8 rssi; uint8 addr[6] } entries, tag = 0x01, and the timestamped variant (ports 162/163) prefixes a big-endian uint32 ts before the tag byte.
GNSS-NA payload (proto_pack_gnss / proto_pack_gnss_ts)
// GHDR, 1 byte (bitfield): bits[0:4] grp_token, bits[5:6] RFU, bit[7] eog
// port 192/193 (no ts): [ GHDR(1) ] [ NAV bytes… ]
// port 194/195 (with ts): [ ts(4, big-endian) ] [ GHDR(1) ] [ NAV bytes… ]
NAV bytes is the raw LR11xx GNSS-NG scan result (opaque, copied verbatim). eog (end-of-group) marks the last message of a multi-message group.
GPS-SA payload (proto_pack_gps_ts, fPort 10)
lorawan_gps_ul_ts_t, all multi-byte fields big-endian (utils_rev16/utils_rev32):
| Offset |
Bytes |
Field |
Type |
Notes (from proto_pack_gps_ts) |
| 0 |
1 |
status |
uint8 |
hardcoded 0 in current code |
| 1 |
4 |
lat |
uint32 BE |
|
| 5 |
4 |
lon |
uint32 BE |
|
| 9 |
2 |
alt |
uint16 BE |
|
| 11 |
4 |
ts |
uint32 BE |
epoch |
| 15 |
2 |
voltage_temp |
uint16 BE |
hardcoded 3700 in current code — combined voltage/temp field, not yet real |
| 17 |
1 |
ttf |
uint8 |
passed as 0 at the call site |
| 18 |
1 |
pdop |
uint8 |
fix.pdop / 100 |
| 19 |
1 |
sats |
uint8 |
satellite count |
Several GPS-SA fields (status, voltage_temp, ttf) are placeholder/hardcoded in this firmware revision — confirm with firmware before relying on them.
Reference
- tagxl-v2 (current decoder baseline):
apps/trackit/settings.h, apps/trackit/main_trackit.c
- ts2 (target, authoritative):
ts2/app/tracker/lorawan_proto.h, ts2/app/tracker/lorawan_proto.c, ts2/app/tracker/logic.c
ts2/Payload format.md — outdated draft, do not use (contradicts the code; see the source-of-truth note at the top)
Acceptance criteria
Tier 1 (do first):
Tier 2 (later — everything else):
Cross-cutting (both tiers):
Decoder: add support for new ts2 (Smart Label v2) payload tags
Summary
Our payload decoder currently supports only the tags defined in the tagxl-v2 firmware (
apps/trackit/settings.h). The ts2 / Smart Label v2 firmware (ts2/app/tracker/lorawan_proto.h) introduces a number of new TLV tags and uplink message types that the decoder does not yet handle. This issue asks the backend team to extend the decoder to cover them.Tier 1 (do first): WiFi AP constraint (Min_AP), motion detection plan v2, battery temperature, and buffer level. Tier 2 (later): everything else — scan timers/quick scans, BLE config, batch count, battery level thresholds, reset count/cause, scan count, and the new localization uplinks. All are specified below.
Priority & sequencing
Tier 1 — implement first
0x2A0x4Duint80x300x600x310x61int8°C0x63uint16BEFull byte-level layouts for all four are in the TLV tag specifications section below.
Tier 2 — later (everything else)
0x320x620x2C0x510x2B0x50uint80x290x4C0x49uint16BE0x4Auint32BE0x4BFull byte-level layouts are in the TLV tag specifications and uplink sections below.
TLV tag specifications
All of the following are defined in
lorawan_proto_tlv_tag_t(ts2/app/tracker/lorawan_proto.h) and are not in the tagxl-v2 decoder set.Settings envelope (applies to every tag below). These TLVs travel inside the settings message (uplink and downlink both on port 151 —
kLoraWanProtoUlSettings/kLoraWanProtoDlSettings). The payload is a 3-byte headerlorawan_proto_settings_header_t={ tag=0x4C, len, count }followed bycountTLVs, each[tag][len][value…]. The header tag0x4Cis verified inproto_decode_settings(kLoraWanTagSettings). All multi-byte integers are big-endian on the wire — firmware byte-swaps everyuint16/uint32withutils_rev16/utils_rev32on both encode and decode. Sizes below are the value length only (excludes the 2-byte tag+len).Each entry verified against the
proto_decode/proto_encodehandlers ints2/app/tracker/logic.c.Motion detection plan v2 — Set
0x30, Get0x60(Tier 1)Size 7 bytes,
lorawan_proto_settings_moving_v2_t. Slice-based movement detection; replaces tagxl's simpleaccel_sensitivity+accel_delay. Set requires exactly 7 bytes.acc_thresh_mguint16BEslice_time_secondsuint8window_slice_countuint8window_slice_requireduint8start_movement_window_countuint8stop_movement_window_countuint8Battery temperature — Set
0x31, Get0x61(Tier 1)Size 1 byte,
int8degrees Celsius. Inlogic.cthe value is cast from the driver'sint32down to a single signed byte (int8_t tmpr = (int8_t) logic.bat.read.temperature), no scaling. Range −128…127 °C; one byte so no endianness concern.Buffer level — Get
0x63only (Tier 1)Size 2 bytes,
uint16BE = number of stored unconfirmed messages (storage.record_count()). Only emitted when the device has the storage capability. No Set handler exists (0x33is reserved in the enum but not implemented).WiFi AP constraint / Min_AP — Set
0x2A, Get0x4D(Tier 1)Size 1 byte,
uint8(wifi_ap_constr). Set requires exactly 1 byte. Semantics (logic.c:2482–2497): it is the minimum number of WiFi APs that makes a WiFi fix "good enough" — when a scan findswifi_scan_count >= wifi_ap_constr(and the value is non-zero), the device skips the GNSS/GPS scan for that cycle. A value of 0 disables the check. Note: the firmware implements this as a single count byte, not the multi-byte "Min RSSI + head/tail + MAC filter" structure described in the draft doc — decode as one byte.Scan timers (quick scans) — Set
0x32, Get0x62(Tier 2 — later)Value is one
lorawan_proto_settings_scan_timer_t= 5 bytes (so the full TLV is[0x32][len][value]), verified against thekLoraWanTlvTagScanTimersSethandler +update_localization_timersinlogic.c.typeuint8perioduint16BEmethoduint8bitfieldcont_opuint8bitfield0x32TLVs (one pertype) are sent to configure multiple timers.type+period(minlen= 3) and copiesMIN(5, len)bytes;method/cont_opmay be omitted.period120–65535 s; Quick variants not range-checked.0x62) is a no-op in this revision (packs no response), so the decoder currently only sees0x32in the Set direction.BLE config — Set
0x2C, Get0x51Size 16 bytes,
lorawan_proto_ble_config_t. Set requires exactly 16 bytes.scan_secondsuint8advert_minutesuint8max_scan_resultuint8min_rssiint8filter_typeuint8filter_datauint8[11]Batch count — Set
0x2B, Get0x50Size 1 byte,
uint8(batch_size). Set requires exactly 1 byte.Battery level thresholds — Set
0x29, Get0x4CSize 2 bytes,
lorawan_proto_battery_levels_t. Set requires exactly 2 bytes.level_criticaluint8level_lowuint8Reset count — Get
0x49onlySize 2 bytes,
uint16BE = number of resets. Read-only counter.Reset cause — Get
0x4AonlySize 4 bytes,
uint32BE = reset-cause code (lorawan_proto_reset_cause_t, values 0–10: 0 Unknown, 1 PowerOn, 2 ResetPin, 3 DeepSleep, 4 WDT, 5 HardFault, 6 Software, 7 HighVoltage, 8 LowVoltage, 9 Debug, 10 DCDC). Read-only.Scan count — Get
0x4BonlySize 4 bytes,
lorawan_proto_settings_scan_count_t. Read-only counter.gnss_scan_countuint16BEwifi_scan_countuint16BElorawan_proto.hthree Set values are reused (0x29= ResetCount/BatteryLevel,0x2A= ResetCause/WifiApConstr,0x2B= ScanCount/BatchCount). The firmware resolves this by implementing the Set direction only for BatteryLevel / WifiApConstr / BatchCount; ResetCount, ResetCause and ScanCount are Get-only read-only counters. Every Get tag is distinct, so decoding uplink (Get) responses is unambiguous — the collision only affects downlink Set of0x29/0x2A/0x2B. Confirm the final mapping with the firmware team.New uplink message types / ports
ts2 introduces localization uplinks that the current decoder does not parse. The message type is selected by the LoRaWAN fPort, not by a payload tag byte. Ports are from the
lorawan_proto_ul_port_tenum inlorawan_proto.h, and the port selection is confirmed inlogic.c(moving vs. steady islogic.loc.moving ? …Moving… : …Steady…; the timestamped variant is used when a timestamp is available).lorawan_proto.c)proto_pack_bleproto_pack_bleproto_pack_ble_tsproto_pack_ble_tsproto_pack_gnssproto_pack_gnssproto_pack_gnss_tsproto_pack_gnss_tsproto_pack_wifiproto_pack_wifiproto_pack_wifi_tsproto_pack_wifi_tsproto_pack_gps_tsWiFi payload (
proto_pack_wifi/proto_pack_wifi_ts)tagis a constant0x01(set inproto_pack_wifi/proto_pack_wifi_ts) — it is not a moving/steady/ts indicator; use the fPort for that.tscomes before the tag byte (lorawan_proto_ul_wifi_ts_t), andtsis big-endian (utils_rev32).rssiis signed.BLE payload (
proto_pack_ble/proto_pack_ble_ts)Identical layout to WiFi: 7-byte
{ int8 rssi; uint8 addr[6] }entries,tag = 0x01, and the timestamped variant (ports 162/163) prefixes a big-endianuint32 tsbefore the tag byte.GNSS-NA payload (
proto_pack_gnss/proto_pack_gnss_ts)NAV bytesis the raw LR11xx GNSS-NG scan result (opaque, copied verbatim).eog(end-of-group) marks the last message of a multi-message group.GPS-SA payload (
proto_pack_gps_ts, fPort 10)lorawan_gps_ul_ts_t, all multi-byte fields big-endian (utils_rev16/utils_rev32):proto_pack_gps_ts)statusuint80in current codelatuint32BElonuint32BEaltuint16BEtsuint32BEvoltage_tempuint16BE3700in current code — combined voltage/temp field, not yet realttfuint80at the call sitepdopuint8fix.pdop / 100satsuint8Reference
apps/trackit/settings.h,apps/trackit/main_trackit.cts2/app/tracker/lorawan_proto.h,ts2/app/tracker/lorawan_proto.c,ts2/app/tracker/logic.cts2/Payload format.md— outdated draft, do not use (contradicts the code; see the source-of-truth note at the top)Acceptance criteria
Tier 1 (do first):
0x2A/0x4D), motion detection plan v2 (0x30/0x60), battery temperature (0x31/0x61), and buffer level (0x63) on settings port 151.Tier 2 (later — everything else):
0x32/0x62), BLE config (0x2C/0x51), batch count (0x2B/0x50), battery level thresholds (0x29/0x4C), reset count (0x49), reset cause (0x4A), and scan count (0x4B) on settings port 151.Cross-cutting (both tiers):
0x01payload tag), and reads all multi-byte fields big-endian.0x29/0x2A/0x2B) and placeholder GPS-SA fields confirmed with firmware and documented.