Skip to content

Uplink dwell time set via MAC settings is not used properly and is silently ignored #7959

Description

@rgracin

Summary

On AU915 devices declared with LoRaWAN MAC version 1.0.4 (mapped to Regional Parameters RP002-1.0.3), setting mac_settings.uplink_dwell_time = false on a device does not prevent the Network Server from treating the device's uplink dwell time as enabled. As a result:

  • The network server sends a TxParamSetupReq MAC command with uplink_dwell_time: true, contradicting the explicit device setting.
  • After the device answers with TxParamSetupAns (although locally it might have not set uplink dwell time value to true), the network server internal mac_state.current_parameters.uplink_dwell_time is unconditionally overwritten to true.
  • Because AU915's RP002-1.0.3 band definition makes DR0 and DR1's maximum MAC payload size 0 bytes when dwell time is true any subsequent real-world uplink sent at DR0 or DR1 is silently dropped by the Network Server before MIC validation with only a debug-level log line and no visible trace in TTN Console Live Data or any application facing error.

This makes it impossible, using currently exposed device settings to reliably operate an AU915 device at DR0/DR1 on The Things Stack Cloud when the device is registered under a LoRaWAN version that maps to RP002-1.0.3. The only practical workaround found is to register the device using LoRaWAN version 1.0.3 (which maps to the legacy RP001-1.0.3-RevA band definition), which sidesteps the bug, not because dwell time is enforced differently, but because that legacy band definition never made DR0–DR6 payload size dwell-time-dependent to begin with (it uses hardcoded constant payload sizes, unlike the spec-correct dwell-time-dependent table in RP002-1.0.3).

Steps to Reproduce

The following was independently reproduced and verified via ttn-lw-cli against a live device (eu1.cloud.thethings.network, TTS v3.36.0):

  1. mac_settings.uplink_dwell_time explicitly set to false; confirmed via end-devices get --mac-settings.
  2. get-default-mac-settings for AU_915_928_FSB_2 / RP002_V1_0_3 shows no default forcing uplink_dwell_time, ruling out a frequency-plan or PHY-version default override.
  3. Explicit end-devices reset --mac-state performed (isolating from join-timing effects).
  4. mac_state.desired_parameters.uplink_dwell_time reads true immediately after reset
  5. TxParamSetupReq observed in Live Data with uplink_dwell_time: true
  6. After the device's TxParamSetupAns, mac_state.current_parameters.uplink_dwell_time which briefly read false immediately post-reset was observed to flip to true
  7. AU915 device uplinks at DR0/DR1 fail to appear in Live Data at all after step 6
  8. Re-registering the same device under LoRaWAN version 1.0.3 (mapping to RP001-1.0.3-RevA) restores DR0/DR1 uplink delivery

Current Result and Issue Cause

The issue spans four functions in pkg/networkserver, plus one clarifying data point in pkg/band.

1. DeviceDesiredUplinkDwellTime never checks mac_settings

File: pkg/networkserver/mac/mac.go

// Used to build CurrentParameters - correctly checks mac_settings
func DeviceUplinkDwellTime(
    dev *ttnpb.EndDevice,
    phy *band.Band,
    defaults *ttnpb.MACSettings,
    profile *ttnpb.MACSettings,
) *ttnpb.BoolValue {
    switch {
    case !phy.TxParamSetupReqSupport:
        return nil
    case profile.GetUplinkDwellTime() != nil:
        return &ttnpb.BoolValue{Value: profile.UplinkDwellTime.Value}
    case dev.GetMacSettings().GetUplinkDwellTime() != nil:
        return &ttnpb.BoolValue{Value: dev.MacSettings.UplinkDwellTime.Value}
    case defaults.GetUplinkDwellTime() != nil:
        return &ttnpb.BoolValue{Value: defaults.UplinkDwellTime.Value}
    default:
        return nil
    }
}

// Used to build DesiredParameters - CANNOT check mac_settings; wrong signature
func DeviceDesiredUplinkDwellTime(phy *band.Band, fp *frequencyplans.FrequencyPlan) *ttnpb.BoolValue {
    switch {
    case !phy.TxParamSetupReqSupport:
        return nil
    case fp.DwellTime.Uplinks != nil:
        return &ttnpb.BoolValue{Value: *fp.DwellTime.Uplinks}
    default:
        return &ttnpb.BoolValue{Value: true}
    }
}

DeviceDesiredUplinkDwellTime does not accept dev, defaults, or profile as parameters at all, so it is structurally unable to consult a device's mac_settings.uplink_dwell_time override. It only ever looks at the frequency plan's DwellTime.Uplinks field, falling back to a hardcoded true if that field is unset.

The AU_915_928_FSB_2 frequency plan (from TheThingsNetwork/lorawan-frequency-plans) only sets dwell-time.downlinks: false and leaves uplinks unset, so this function always falls through to the hardcoded default of true, regardless of any device-level setting.

The exact same defect exists in the sibling function DeviceDesiredDownlinkDwellTime.

2. NewState uses the broken function for DesiredParameters

File: pkg/networkserver/mac/mac.go, function NewState (called on every OTAA join and on end-devices reset --mac-state):

current := &ttnpb.MACParameters{
    ...
    UplinkDwellTime: DeviceUplinkDwellTime(dev, phy, defaults, profile), // correct - respects mac_settings
    ...
}
desired := &ttnpb.MACParameters{
    ...
    UplinkDwellTime: DeviceDesiredUplinkDwellTime(phy, fp), // broken - ignores mac_settings
    ...
}

This produces CurrentParameters.UplinkDwellTime = false (correctly reflecting the device's mac_settings override) alongside DesiredParameters.UplinkDwellTime = true (always, regardless of mac settings). Because current != desired, DeviceNeedsTxParamSetupReq (in pkg/networkserver/mac/tx_param_setup.go) returns true, and the NS enqueues a TxParamSetupReq MAC command with uplink_dwell_time: true in its payload even though the device explicitly asked for false.

3. HandleTxParamSetupAns blindly trusts the Ans and corrupts CurrentParameters

File: pkg/networkserver/mac/tx_param_setup.go

func HandleTxParamSetupAns(ctx context.Context, dev *ttnpb.EndDevice) (events.Builders, error) {
    ...
    func(cmd *ttnpb.MACCommand) error {
        req := cmd.GetTxParamSetupReq()

        dev.MacState.CurrentParameters.MaxEirp = lorawan.DeviceEIRPToFloat32(req.MaxEirpIndex)
        dev.MacState.CurrentParameters.DownlinkDwellTime = &ttnpb.BoolValue{Value: req.DownlinkDwellTime}
        dev.MacState.CurrentParameters.UplinkDwellTime = &ttnpb.BoolValue{Value: req.UplinkDwellTime}

        if lorawan.Float32ToDeviceEIRP(dev.MacState.DesiredParameters.MaxEirp) == req.MaxEirpIndex {
            dev.MacState.DesiredParameters.MaxEirp = dev.MacState.CurrentParameters.MaxEirp
        }
        return nil
    },
    ...
}

TxParamSetupAns, per the LoRaWAN specification, is a bare acknowledgment with no payload it carries no information about whether the device actually applied the requested dwell-time setting. This handler unconditionally copies the requested value (req.UplinkDwellTime) into CurrentParameters.UplinkDwellTime assuming full compliance because there is not acknowledge mechanism defined per the LoRaWAN specification.
This means that even a device that ignores the network's dwell-time request will have the Network Servers own state overwritten to falsely reflect the network's request rather than the device's actual behavior.

The net effect: even though NewState() initially sets CurrentParameters.UplinkDwellTime = false correctly this correct value is overwritten to true as soon as the device answers the very first TxParamSetupReq which happens within seconds of every join since it is generated as a mandatory MAC command.

4. The corrupted CurrentParameters.UplinkDwellTime silently drops uplinks

File: pkg/networkserver/grpc_gsrpc.go, function matchAndHandleDataUplink:

// NOTE: We assume no dwell time if current value unknown.
if macspec.IgnoreUplinksExceedingLengthLimit(dev.MacState.LorawanVersion) &&
   len(up.RawPayload)-5 > int(dr.MaxMACPayloadSize(dev.MacState.CurrentParameters.UplinkDwellTime.GetValue())) {
    log.FromContext(ctx).Debug("Uplink length exceeds maximum")
    return nil, false, nil
}

dr.MaxMACPayloadSize(dwellTime) for AU915 DR0/DR1 (band AU_915_928_RP2_v1_0_3, pkg/band/au_915_928.go) is defined as:

ttnpb.DataRateIndex_DATA_RATE_0: makeLoRaDataRate(12, 125000, Cr4_5, makeDwellTimeMaxMACPayloadSizeFunc(59, 0)),
ttnpb.DataRateIndex_DATA_RATE_1: makeLoRaDataRate(11, 125000, Cr4_5, makeDwellTimeMaxMACPayloadSizeFunc(59, 0)),

With dwellTime = true, MaxMACPayloadSize returns 0 for both DR0 and DR1. This causes any real uplink to be dropped with no event in Live Data only a debug-level server log line which is not accessible by the user.

5. The RP002 Regional Parameters 1.0.3 revision A workaround, that is not really a fix

File: pkg/band/au_915_928.go, comparing the two band definitions:

// RP002-1.0.3 (spec-correct, dwell-time-dependent - exposed to the bug)
ttnpb.DataRateIndex_DATA_RATE_0: makeLoRaDataRate(12, 125000, Cr4_5, makeDwellTimeMaxMACPayloadSizeFunc(59, 0)),

// RP001-1.0.3-RevA (legacy, dwell-time-independent - immune to the bug)
ttnpb.DataRateIndex_DATA_RATE_0: makeLoRaDataRate(12, 125000, Cr4_5, makeConstMaxMACPayloadSizeFunc(59)),

makeConstMaxMACPayloadSizeFunc(59) ignores its dwellTime argument entirely and always returns 59. Registering the device as LoRaWAN Specification 1.0.3 (which maps to RP001-1.0.3-RevA) does not actually correct the dwell-time handling bug it simply uses an older band definition that never made DR0–DR6 payload size dwell-time-dependent in the first place and is therefore structurally immune to the corrupted CurrentParameters.UplinkDwellTime value. RP002-1.0.3 is the spec-correct implementation and is precisely why it is the version affected.


Expected Result

Uplink dwell time set in MAC settings to false should be honored throughout the device's MAC state, so DR0/DR1 uplinks are accepted rather than silently dropped regardless of the declared LoRaWAN version or associated Regional Parameters revision.

URL

https://eu1.cloud.thethings.network/console

Deployment

The Things Stack Cloud

The Things Stack Version

3.3.6

Client Name and Version

The Things Network Command-line Interface: ttn-lw-cli
Version:             3.36.0
Build date:          2026-04-03T08:54:04Z
Git commit:          cba826ea5
Go version:          go1.24.13
OS/Arch:             linux/amd64

Other Information

No response

Proposed Fix

Change the function signatures to match their Device*DwellTime (current-parameters) counterparts, and apply the same precedence order (profiledev.MacSettingsdefaults → frequency plan → hardcoded default):

func DeviceDesiredUplinkDwellTime(
    dev *ttnpb.EndDevice,
    phy *band.Band,
    fp *frequencyplans.FrequencyPlan,
    defaults *ttnpb.MACSettings,
    profile *ttnpb.MACSettings,
) *ttnpb.BoolValue {
    switch {
    case !phy.TxParamSetupReqSupport:
        return nil
    case profile.GetUplinkDwellTime() != nil:
        return &ttnpb.BoolValue{Value: profile.UplinkDwellTime.Value}
    case dev.GetMacSettings().GetUplinkDwellTime() != nil:
        return &ttnpb.BoolValue{Value: dev.MacSettings.UplinkDwellTime.Value}
    case defaults.GetUplinkDwellTime() != nil:
        return &ttnpb.BoolValue{Value: defaults.UplinkDwellTime.Value}
    case fp.DwellTime.Uplinks != nil:
        return &ttnpb.BoolValue{Value: *fp.DwellTime.Uplinks}
    default:
        return &ttnpb.BoolValue{Value: true}
    }
}

Update the call site in NewState() accordingly:

UplinkDwellTime: DeviceDesiredUplinkDwellTime(dev, phy, fp, defaults, profile),

Apply the identical change to DeviceDesiredDownlinkDwellTime.

4.1 alone resolves the reported symptom (uplinks being dropped) because it removes the root cause forcing an unnecessary and incorrect TxParamSetupReq in the first place. 4.2 addresses the deeper design gap around trusting an Ans that cannot carry compliance information, which affects any scenario where a device's actual dwell-time state differs from what the network most recently requested.

Contributing

  • I can help by doing more research.
  • I can help by implementing a fix after the proposal above is approved.
  • I can help by testing the fix before it's released.

Validation

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs/triageWe still need to triage this

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions