Repository: viam-modules/lorawan
Pull request #51: RSDK-10335 enforce duty cycle regulation on EU devices
Author: login: oliviamiller
Created: 2025-03-31T19:50:49Z | Merged: 2025-04-04T16:13:05Z

Sends a DutycycleReq to EU devices to limit the duty cycle. The device will limit its uplinks based on the num in this request. The device can send a dutycycleAns but according to docs this is not required to be sent by the devices. 

Logs a warning with an estimation of the minimum uplink interval based on the uplink's size and spread factor. For the dragino sensors sending at a SF of 10 and payload size of 24 bytes, the min interval is 47 seconds. Also saves this number to the node struct and logs a warning if the user tries to set an interval below the min.

Confirmed US gateway still works, wasn't able to test the dutyCycleReq in the US 


Diff:
diff --git a/draginolht65n/sensor.go b/draginolht65n/sensor.go
index 6795ec0..b6640c7 100644
--- a/draginolht65n/sensor.go
+++ b/draginolht65n/sensor.go
@@ -123,6 +123,9 @@ func (n *LHT65N) Reconfigure(ctx context.Context, deps resource.Dependencies, co
 		return err
 	}
 
+	// call this once outside of background thread to get any info gateway has before calling the interval request.
+	n.node.GetAndUpdateDeviceInfo(ctx)
+
 	// set the interval if one was provided
 	// we do not send a default in case the user has already set an interval they prefer
 	if cfg.Interval != nil && *cfg.Interval != 0 {
diff --git a/gateway/downlink.go b/gateway/downlink.go
index 19f56b9..d8bbfef 100644
--- a/gateway/downlink.go
+++ b/gateway/downlink.go
@@ -10,22 +10,20 @@ import (
 
 	"github.com/viam-modules/gateway/lorahw"
 	"github.com/viam-modules/gateway/node"
+	"github.com/viam-modules/gateway/regions"
 	"go.thethings.network/lorawan-stack/v3/pkg/crypto"
 	"go.thethings.network/lorawan-stack/v3/pkg/types"
 	"go.viam.com/utils"
 )
 
 const (
-	joinDelay      = 6         // rx2 delay in seconds for sending join accept message.
-	downlinkDelay  = 2         // rx2 delay in seconds for downlink messages.
-	rx2FrequencyUS = 923300000 // rx2 default freq for US
-	rx2FrequencyEU = 869525000 // rx2 default freq for EU region
-	rx2SF          = 12        // spreading factor for rx2 window, used for both US and EU
-	rx2BandwidthUS = 0x06      // 500k bandwidth for US downlinks
-	rx2BandwidthEU = 0x04      // 125k bandwidth for EU downlinks
+	joinDelay     = 6  // rx2 delay in seconds for sending join accept message.
+	downlinkDelay = 2  // rx2 delay in seconds for downlink messages.
+	rx2SF         = 12 // spreading factor for rx2 window, used for both US and EU
 	// command identifiers of supported mac commands.
 	deviceTimeCID = 0x0D
 	linkCheckCID  = 0x02
+	dutyCycleCID  = 0x04
 )
 
 func (g *gateway) sendDownlink(ctx context.Context, payload []byte, isJoinAccept bool, packetTime time.Time) error {
@@ -91,8 +89,12 @@ func accurateSleep(ctx context.Context, duration time.Duration) bool {
 // Downlink payload structure
 // | MHDR | DEV ADDR | FCTRL | FCNTDOWN |  FOPTS (optional)  |  FPORT | encrypted frame payload  |  MIC |
 // | 1 B  |   4 B    |  1 B  |    2 B   |       variable     |   1 B  |      variable            | 4 B  |.
-func (g *gateway) createDownlink(ctx context.Context, device *node.Node, framePayload, uplinkFopts []byte,
-	sendAck bool, snr float64, sf int) (
+func (g *gateway) createDownlink(ctx context.Context,
+	device *node.Node,
+	framePayload, uplinkFopts []byte,
+	sendAck bool,
+	snr float64,
+	sf int) (
 	[]byte, error,
 ) {
 	payload := make([]byte, 0)
@@ -121,12 +123,21 @@ func (g *gateway) createDownlink(ctx context.Context, device *node.Node, framePa
 				g.logger.Debugf("got link check request from %s", device.NodeName)
 				linkCheckAns := createLinkCheckAns(snr, sf)
 				fopts = append(fopts, linkCheckAns...)
+			case dutyCycleCID:
+				g.logger.Debugf("got duty cycle answer from %s", device.NodeName)
 			default:
 				// unknown mac command - this shouldn't happen since we remove these in parseDataUplink.
 			}
 		}
 	}
 
+	// Send the dutycycleReq on the first downlink if EU region
+	if device.Region == regions.EU && device.FCntDown == 0 {
+		dutyCycleReq := createDutyCycleReq()
+		fopts = append(fopts, dutyCycleReq...)
+		g.logger.Debugf("sending duty cycle request to %s", device.NodeName)
+	}
+
 	//  FCtrl: ADR (1), RFU (0), ACK(0/1), FPending (0), FOptsLen (0000)
 	fctrl := byte(0x80)
 	if sendAck {
@@ -171,12 +182,13 @@ func (g *gateway) createDownlink(ctx context.Context, device *node.Node, framePa
 
 	// create new deviceInfo to update the fcntDown in the file.
 	deviceInfo := deviceInfo{
-		DevEUI:   fmt.Sprintf("%X", device.DevEui),
-		DevAddr:  fmt.Sprintf("%X", device.Addr),
-		AppSKey:  fmt.Sprintf("%X", device.AppSKey),
-		NwkSKey:  fmt.Sprintf("%X", device.NwkSKey),
-		FCntDown: &device.FCntDown,
-		NodeName: device.NodeName,
+		DevEUI:            fmt.Sprintf("%X", device.DevEui),
+		DevAddr:           fmt.Sprintf("%X", device.Addr),
+		AppSKey:           fmt.Sprintf("%X", device.AppSKey),
+		NwkSKey:           fmt.Sprintf("%X", device.NwkSKey),
+		FCntDown:          &device.FCntDown,
+		NodeName:          device.NodeName,
+		MinUplinkInterval: device.MinIntervalSeconds,
 	}
 	ctxTimeout, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
 	defer cancel()
@@ -241,3 +253,12 @@ func createLinkCheckAns(snr float64, sf int) []byte {
 
 	return payload
 }
+
+// EU devices have limitations on how often they can use the frequency band.
+// duty cycle = 1/2^(MaxDCycle).
+func createDutyCycleReq() []byte {
+	payload := make([]byte, 0)
+	payload = append(payload, dutyCycleCID)
+	payload = append(payload, 0x07) // 2^-7 = 0.78% duty cyle, closest to 1% we can get.
+	return payload
+}
diff --git a/gateway/downlink_test.go b/gateway/downlink_test.go
index dec9cd6..f227de7 100644
--- a/gateway/downlink_test.go
+++ b/gateway/downlink_test.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/viam-modules/gateway/node"
+	"github.com/viam-modules/gateway/regions"
 	"go.thethings.network/lorawan-stack/v3/pkg/crypto"
 	"go.thethings.network/lorawan-stack/v3/pkg/types"
 	"go.viam.com/rdk/logging"
@@ -28,6 +29,7 @@ func TestCreateDownlink(t *testing.T) {
 		expectedLength      int
 		expectedFctrl       byte
 		expectedFOptsLength int
+		expectDutyCycleReq  bool
 	}{
 		{
 			name: "valid downlink with standard payload",
@@ -39,6 +41,7 @@ func TestCreateDownlink(t *testing.T) {
 				FCntDown: 0,
 				FPort:    0x01,
 				DevEui:   testDevEUI,
+				Region:   regions.US,
 			},
 			framePayload:   []byte{0x01, 0x02, 0x03, 0x04},
 			expectedErr:    false,
@@ -57,6 +60,7 @@ func TestCreateDownlink(t *testing.T) {
 				FCntDown: 0,
 				FPort:    0x01,
 				DevEui:   testDevEUI,
+				Region:   regions.US,
 			},
 			framePayload:   []byte{0x01, 0x02, 0x03, 0x04},
 			expectedErr:    false,
@@ -75,6 +79,7 @@ func TestCreateDownlink(t *testing.T) {
 				FCntDown: 0,
 				FPort:    0x01,
 				DevEui:   testDevEUI,
+				Region:   regions.US,
 			},
 			expectedErr:    false,
 			ack:            true,
@@ -92,6 +97,7 @@ func TestCreateDownlink(t *testing.T) {
 				FCntDown: 0,
 				FPort:    0x01,
 				DevEui:   testDevEUI,
+				Region:   regions.US,
 			},
 			uplinkFopts:         []byte{deviceTimeCID},
 			expectedErr:         false,
@@ -110,6 +116,7 @@ func TestCreateDownlink(t *testing.T) {
 				FCntDown: 0,
 				FPort:    0x01,
 				DevEui:   testDevEUI,
+				Region:   regions.US,
 			},
 			framePayload:        []byte{0x01, 0x02, 0x03, 0x04},
 			uplinkFopts:         []byte{linkCheckCID},
@@ -120,21 +127,24 @@ func TestCreateDownlink(t *testing.T) {
 			expectedFOptsLength: 3,
 		},
 		{
-			name: "invalid fport should return error",
+			name: "downlink with duty cycle request",
 			device: &node.Node{
 				NodeName: testNodeName,
 				Addr:     testDeviceAddr,
 				AppSKey:  testAppSKey,
 				NwkSKey:  testNwkSKey,
 				FCntDown: 0,
-				FPort:    0x00,
+				FPort:    0x01,
 				DevEui:   testDevEUI,
+				Region:   regions.EU,
 			},
-			framePayload:  []byte{0x01, 0x02, 0x03, 0x04},
-			expectedErr:   true,
-			ack:           true,
-			uplinkFopts:   nil,
-			expectedFctrl: 0xA0,
+			framePayload:        []byte{0x01, 0x02, 0x03, 0x04},
+			expectedErr:         false,
+			ack:                 false,
+			expectedLength:      19, // base length (17) + duty cycle request (2 bytes)
+			expectedFctrl:       0x82,
+			expectedFOptsLength: 2,
+			expectDutyCycleReq:  true,
 		},
 		{
 			name: "downlink with devicetimeans, linkcheckans, and ignore unknown command",
@@ -146,6 +156,7 @@ func TestCreateDownlink(t *testing.T) {
 				FCntDown: 0,
 				FPort:    0x01,
 				DevEui:   testDevEUI,
+				Region:   regions.US,
 			},
 			uplinkFopts:         []byte{deviceTimeCID, linkCheckCID, 0x01},
 			expectedErr:         false,
@@ -154,6 +165,24 @@ func TestCreateDownlink(t *testing.T) {
 			expectedFctrl:       0x89,
 			expectedFOptsLength: 9,
 		},
+		{
+			name: "invalid fport should return error",
+			device: &node.Node{
+				NodeName: testNodeName,
+				Addr:     testDeviceAddr,
+				AppSKey:  testAppSKey,
+				NwkSKey:  testNwkSKey,
+				FCntDown: 0,
+				FPort:    0x00,
+				DevEui:   testDevEUI,
+				Region:   regions.US,
+			},
+			framePayload:  []byte{0x01, 0x02, 0x03, 0x04},
+			expectedErr:   true,
+			ack:           true,
+			uplinkFopts:   nil,
+			expectedFctrl: 0xA0,
+		},
 	}
 
 	for _, tt := range tests {
@@ -189,7 +218,7 @@ func TestCreateDownlink(t *testing.T) {
 				test.That(t, payload[5], test.ShouldEqual, tt.expectedFctrl)
 
 				currentPos := 8 // Start after MHDR(1) + DevAddr(4) + FCtrl(1) + FCnt(2)
-				if tt.uplinkFopts != nil {
+				if tt.expectedFOptsLength != 0 {
 					for _, b := range tt.uplinkFopts {
 						if b == deviceTimeCID {
 							test.That(t, payload[currentPos], test.ShouldEqual, deviceTimeCID)
@@ -200,11 +229,15 @@ func TestCreateDownlink(t *testing.T) {
 							currentPos += 3
 						}
 					}
-
-					actualFOptsLength := currentPos - 8
-					test.That(t, actualFOptsLength, test.ShouldEqual, tt.expectedFOptsLength)
+					if tt.expectDutyCycleReq {
+						test.That(t, payload[currentPos], test.ShouldEqual, dutyCycleCID)
+						currentPos += 2
+					}
 				}
 
+				actualFOptsLength := currentPos - 8
+				test.That(t, actualFOptsLength, test.ShouldEqual, tt.expectedFOptsLength)
+
 				if tt.framePayload != nil {
 					portIndex := currentPos
 					test.That(t, payload[portIndex], test.ShouldEqual, tt.device.FPort)
@@ -284,3 +317,10 @@ func TestCreateLinkCheckAns(t *testing.T) {
 	// Verify gateway count is always 1
 	test.That(t, linkCheckAns[2], test.ShouldEqual, byte(1))
 }
+
+func TestCreateDutyCycleReq(t *testing.T) {
+	req := createDutyCycleReq()
+	test.That(t, len(req), test.ShouldEqual, 2)
+	test.That(t, req[0], test.ShouldEqual, dutyCycleCID)
+	test.That(t, req[1], test.ShouldEqual, 0x07)
+}
diff --git a/gateway/gateway.go b/gateway/gateway.go
index 1fde306..0206433 100644
--- a/gateway/gateway.go
+++ b/gateway/gateway.go
@@ -176,6 +176,7 @@ type gateway struct {
 	logWriter  *os.File
 	db         *sql.DB // store device information/keys for use across restarts in a database
 	regionInfo regions.RegionInfo
+	region     regions.Region
 }
 
 // NewGateway creates a new gateway.
@@ -282,13 +283,16 @@ func (g *gateway) Reconfigure(ctx context.Context, deps resource.Dependencies, c
 	}
 
 	region := regions.GetRegion(cfg.Region)
+
 	switch region {
 	case regions.US, regions.Unspecified:
 		g.logger.Infof("configuring gateway for US915 band")
 		g.regionInfo = regions.RegionInfoUS
+		g.region = regions.US
 	case regions.EU:
 		g.logger.Infof("configuring gateway for EU868 band")
 		g.regionInfo = regions.RegionInfoEU
+		g.region = regions.EU
 	}
 
 	if err := lorahw.SetupGateway(cfg.Bus, region); err != nil {
@@ -473,9 +477,9 @@ func (g *gateway) updateReadings(name string, newReadings map[string]interface{}
 func (g *gateway) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {
 	g.mu.Lock()
 	defer g.mu.Unlock()
-	// Validate that the dependency is correct.
+	// Validate that the dependency is correct, returns the gateway's region.
 	if _, ok := cmd["validate"]; ok {
-		return map[string]interface{}{"validate": 1}, nil
+		return map[string]interface{}{"validate": g.region}, nil
 	}
 
 	// Add the nodes to the list of devices.
@@ -548,6 +552,7 @@ func (g *gateway) DoCommand(ctx context.Context, cmd map[string]interface{}) (ma
 
 		return map[string]interface{}{node.GatewaySendDownlinkKey: "downlink added"}, nil
 	}
+
 	// return all devices that have been registered on the gateway
 	if _, ok := cmd["return_devices"]; ok {
 		resp := map[string]interface{}{}
@@ -562,6 +567,26 @@ func (g *gateway) DoCommand(ctx context.Context, cmd map[string]interface{}) (ma
 		return resp, nil
 	}
 
+	if devEUI, ok := cmd[node.GetDeviceKey]; ok {
+		resp := map[string]interface{}{}
+		deveui, ok := (devEUI).(string)
+		if !ok {
+			return nil, fmt.Errorf("expected a string but got %v", reflect.TypeOf(devEUI))
+		}
+		// Read the device info from the db
+		device, err := g.findDeviceInDB(ctx, deveui)
+		if err != nil {
+			if errors.Is(err, errNoDeviceInDB) {
+				// the device hasn't joined yet - return nil to device
+				resp[node.GetDeviceKey] = nil
+				return resp, nil
+			}
+			return nil, fmt.Errorf("failed to read device info from db: %w", err)
+		}
+		resp[node.GetDeviceKey] = device
+		return resp, nil
+	}
+
 	return map[string]interface{}{}, nil
 }
 
@@ -586,6 +611,7 @@ func (g *gateway) updateDeviceInfo(device *node.Node, d *deviceInfo) error {
 	device.AppSKey = appsKey
 	device.Addr = savedAddr
 	device.NwkSKey = nwksKey
+	device.MinIntervalSeconds = d.MinUplinkInterval
 
 	// if we don't have an FCntDown in the device file, set it to a max number so we can tell.
 	device.FCntDown = math.MaxUint16
@@ -605,6 +631,7 @@ func mergeNodes(newNode, oldNode *node.Node) (*node.Node, error) {
 	mergedNode.NodeName = newNode.NodeName
 	mergedNode.JoinType = newNode.JoinType
 	mergedNode.FPort = newNode.FPort
+	mergedNode.MinIntervalSeconds = newNode.MinIntervalSeconds
 
 	switch mergedNode.JoinType {
 	case "OTAA":
diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go
index 7b6a387..ec7cf33 100644
--- a/gateway/gateway_test.go
+++ b/gateway/gateway_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/viam-modules/gateway/node"
+	"github.com/viam-modules/gateway/regions"
 	"go.viam.com/rdk/components/sensor"
 	"go.viam.com/rdk/data"
 	"go.viam.com/rdk/logging"
@@ -134,7 +135,7 @@ func TestDoCommand(t *testing.T) {
 	test.That(t, err, test.ShouldBeNil)
 	retVal, ok := resp["validate"]
 	test.That(t, ok, test.ShouldBeTrue)
-	test.That(t, retVal.(int), test.ShouldEqual, 1)
+	test.That(t, retVal.(regions.Region), test.ShouldEqual, regions.US)
 
 	// need to simulate what happens when the DoCommand message is serialized/deserialized into proto
 	doOverWire := func(gateway sensor.Sensor, cmd map[string]interface{}) {
@@ -192,6 +193,29 @@ func TestDoCommand(t *testing.T) {
 	doOverWire(s, removeCmd)
 	test.That(t, len(g.devices), test.ShouldEqual, 0)
 
+	// Test GetDevice command
+	cmd := make(map[string]interface{})
+	cmd[node.GetDeviceKey] = fmt.Sprintf("%X", testDevEUI)
+	resp, err = s.DoCommand(context.Background(), cmd)
+	test.That(t, err, test.ShouldBeNil)
+	deviceInfo, ok := resp[node.GetDeviceKey].(deviceInfo)
+	test.That(t, ok, test.ShouldBeTrue)
+	test.That(t, deviceInfo.DevEUI, test.ShouldEqual, fmt.Sprintf("%X", testDevEUI))
+	test.That(t, deviceInfo.DevAddr, test.ShouldEqual, fmt.Sprintf("%X", testDeviceAddr))
+	test.That(t, deviceInfo.AppSKey, test.ShouldEqual, fmt.Sprintf("%X", testAppSKey))
+
+	// Test GetDeviceKey with invalid devEUI type
+	cmd[node.GetDeviceKey] = 123
+	_, err = s.DoCommand(context.Background(), cmd)
+	test.That(t, err, test.ShouldNotBeNil)
+	test.That(t, err.Error(), test.ShouldContainSubstring, "expected a string")
+
+	// Test GetDeviceKey with non-existent device
+	cmd[node.GetDeviceKey] = "09876655"
+	ret, err := s.DoCommand(context.Background(), cmd)
+	test.That(t, err, test.ShouldBeNil)
+	test.That(t, ret[node.GetDeviceKey], test.ShouldBeNil)
+
 	// test sendDownlink command
 	testDownLinkPayload := "ff03"
 
diff --git a/gateway/test_utils.go b/gateway/test_utils.go
index a1d61b4..1fcf4c9 100644
--- a/gateway/test_utils.go
+++ b/gateway/test_utils.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/viam-modules/gateway/node"
+	"github.com/viam-modules/gateway/regions"
 	"go.viam.com/rdk/logging"
 	"go.viam.com/test"
 )
@@ -56,6 +57,7 @@ func createTestGateway(t *testing.T) *gateway {
 	g := gateway{
 		logger:  logging.NewTestLogger(t),
 		devices: testDevices,
+		region:  regions.US,
 	}
 	err := g.setupSqlite(context.Background(), dataDirectory1)
 	test.That(t, err, test.ShouldBeNil)
diff --git a/gateway/uplink.go b/gateway/uplink.go
index 34ac466..6c714ea 100644
--- a/gateway/uplink.go
+++ b/gateway/uplink.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/robertkrimen/otto"
 	"github.com/viam-modules/gateway/node"
+	"github.com/viam-modules/gateway/regions"
 	"go.thethings.network/lorawan-stack/v3/pkg/crypto"
 	"go.thethings.network/lorawan-stack/v3/pkg/types"
 )
@@ -98,7 +99,16 @@ func (g *gateway) parseDataUplink(ctx context.Context, phyPayload []byte, packet
 		device.Downlinks = device.Downlinks[1:]
 	}
 
-	if downlinkPayload != nil || len(requests) > 0 || sendAck {
+	// set the duty cycle in first downlink if in EU region.
+	setDutyCycle := false
+	if g.region == regions.EU && device.FCntDown == 0 {
+		setDutyCycle = true
+		minInterval := calculateMinUplinkInterval(sf, len(phyPayload))
+		g.logger.Warnf("Duty cycle limit on EU868 band is 1%%, minimum uplink interval is around %.1f seconds", minInterval)
+		device.MinIntervalSeconds = minInterval
+	}
+
+	if downlinkPayload != nil || len(requests) > 0 || sendAck || setDutyCycle {
 		if len(device.NwkSKey) == 0 || device.FCntDown == math.MaxUint16 {
 			g.logger.Warnf("Sensor %v must be reset to support new features. "+
 				"Please physically restart the sensor to enable downlinks", device.NodeName)
@@ -287,6 +297,8 @@ func (g *gateway) getFOptsToSend(fopts []byte, device *node.Node) []byte {
 			requests = append(requests, b)
 		case linkCheckCID:
 			requests = append(requests, b)
+		case dutyCycleCID:
+			// do nothing, response
 		default:
 			// unsupported mac command
 			g.logger.Debugf("got unsupported mac command %x from %s", b, device.NodeName)
@@ -294,3 +306,39 @@ func (g *gateway) getFOptsToSend(fopts []byte, device *node.Node) []byte {
 	}
 	return requests
 }
+
+// Calculate an estimated minimum uplink interval based on the sf and size of the uplink.
+// Formula for TOA can be found in semtech's sx1262 datasheet 6.1.4:
+// https://semtech.my.salesforce.com/sfc/p/#E0000000JelG/a/2R000000Un7F/yT.fKdAr9ZAo3cJLc4F2cBdUsMftpT2vsOICP7NmvMo
+func calculateMinUplinkInterval(sf, size int) float64 {
+	bw := 125000.0
+	dutyCycle := 0.0078125      // 0.78% duty cycle
+	preambleSymbols := 8        // symbols in lora packet preamble
+	overheadSymbols := 4.25     // other symbols in lora packet
+	syncWordSymbols := 8        // overhead for sync word
+	explicitHeaderSymbols := 20 // lora explicit header
+	codeRate := 1               // lora packets using 4/5 coderate so use 1
+	crcNumBits := 16            // num bits in crc
+
+	payloadBits := 8 * size // get the number of bits of the payload
+	payloadBits += crcNumBits
+	payloadBits -= 4 * (sf) // Unclear what the -4 does, not explained in datasheet.
+	payloadBits += 8        // Mystery number from datasheet formula
+	payloadBits += explicitHeaderSymbols
+
+	bitsPerSymbol := float64(sf)
+
+	payloadSymbols := math.Ceil(float64(max(payloadBits, 0))/(4*bitsPerSymbol)) * float64(codeRate+4)
+	totalSymbols := payloadSymbols + float64(preambleSymbols) + overheadSymbols + float64(syncWordSymbols)
+
+	// calculate time on air
+	timePerSymbol := math.Pow(2, float64(sf)) / bw
+	toa := totalSymbols * timePerSymbol
+
+	// total time available for uplinks in an hour
+	timeav := dutyCycle * 3600
+	uplinksPerHour := timeav / toa
+	minIntervalSeconds := 3600 / uplinksPerHour
+
+	return minIntervalSeconds
+}
diff --git a/gateway/uplink_test.go b/gateway/uplink_test.go
index 973b7ea..fff1a63 100644
--- a/gateway/uplink_test.go
+++ b/gateway/uplink_test.go
@@ -245,3 +245,9 @@ func TestConvertTo32Bit(t *testing.T) {
 	result = convertTo32Bit(input)
 	test.That(t, result, test.ShouldEqual, input)
 }
+
+func TestCalculateMinUplinkInterval(t *testing.T) {
+	expected := 47.448063999999995
+	actual := calculateMinUplinkInterval(10, 24)
+	test.That(t, expected, test.ShouldEqual, actual)
+}
diff --git a/go.mod b/go.mod
index ebe20f4..916b702 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/viam-modules/gateway
 
-go 1.23
+go 1.23.0
 
 toolchain go1.23.2
 
@@ -11,7 +11,7 @@ require (
 	go.thethings.network/lorawan-stack/v3 v3.32.0
 	go.viam.com/rdk v0.66.0
 	go.viam.com/test v1.2.4
-	go.viam.com/utils v0.1.130
+	go.viam.com/utils v0.1.137
 )
 
 require (
@@ -20,7 +20,7 @@ require (
 	cloud.google.com/go v0.115.1 // indirect
 	cloud.google.com/go/auth v0.9.4 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
-	cloud.google.com/go/compute/metadata v0.5.1 // indirect
+	cloud.google.com/go/compute/metadata v0.6.0 // indirect
 	cloud.google.com/go/iam v1.2.0 // indirect
 	cloud.google.com/go/storage v1.43.0 // indirect
 	git.sr.ht/~sbinet/gg v0.3.1 // indirect
@@ -84,8 +84,7 @@ require (
 	github.com/docker/go-units v0.5.0 // indirect
 	github.com/edaniels/golog v0.0.0-20230215213219-28954395e8d0 // indirect
 	github.com/edaniels/lidario v0.0.0-20220607182921-5879aa7b96dd // indirect
-	github.com/edaniels/zeroconf v1.0.10 // indirect
-	github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
+	github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
 	github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
 	github.com/ettle/strcase v0.2.0 // indirect
 	github.com/fatih/color v1.17.0 // indirect
@@ -238,7 +237,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/polyfloyd/go-errorlint v1.6.0 // indirect
 	github.com/prometheus/client_golang v1.19.1 // indirect
-	github.com/prometheus/client_model v0.6.0 // indirect
+	github.com/prometheus/client_model v0.6.1 // indirect
 	github.com/prometheus/common v0.48.0 // indirect
 	github.com/prometheus/procfs v0.15.1 // indirect
 	github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
@@ -286,6 +285,7 @@ require (
 	github.com/ultraware/whitespace v0.1.1 // indirect
 	github.com/uudashr/gocognit v1.1.3 // indirect
 	github.com/viamrobotics/webrtc/v3 v3.99.10 // indirect
+	github.com/viamrobotics/zeroconf v1.0.12 // indirect
 	github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	github.com/wlynxg/anet v0.0.3 // indirect
@@ -307,27 +307,28 @@ require (
 	go-simpler.org/sloglint v0.7.2 // indirect
 	go.mongodb.org/mongo-driver v1.17.1 // indirect
 	go.opencensus.io v0.24.0 // indirect
+	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
-	go.opentelemetry.io/otel v1.32.0 // indirect
-	go.opentelemetry.io/otel/metric v1.32.0 // indirect
-	go.opentelemetry.io/otel/trace v1.32.0 // indirect
+	go.opentelemetry.io/otel v1.34.0 // indirect
+	go.opentelemetry.io/otel/metric v1.34.0 // indirect
+	go.opentelemetry.io/otel/trace v1.34.0 // indirect
 	go.uber.org/automaxprocs v1.5.3 // indirect
 	go.uber.org/goleak v1.3.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.27.0 // indirect
-	go.viam.com/api v0.1.397 // indirect
+	go.viam.com/api v0.1.408 // indirect
 	go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect
-	golang.org/x/crypto v0.31.0 // indirect
+	golang.org/x/crypto v0.36.0 // indirect
 	golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
 	golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
 	golang.org/x/image v0.19.0 // indirect
 	golang.org/x/mod v0.22.0 // indirect
-	golang.org/x/net v0.31.0 // indirect
-	golang.org/x/oauth2 v0.23.0 // indirect
-	golang.org/x/sync v0.10.0 // indirect
-	golang.org/x/sys v0.29.0 // indirect
-	golang.org/x/text v0.21.0 // indirect
+	golang.org/x/net v0.37.0 // indirect
+	golang.org/x/oauth2 v0.25.0 // indirect
+	golang.org/x/sync v0.12.0 // indirect
+	golang.org/x/sys v0.31.0 // indirect
+	golang.org/x/text v0.23.0 // indirect
 	golang.org/x/time v0.6.0 // indirect
 	golang.org/x/tools v0.27.0 // indirect
 	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
@@ -335,10 +336,10 @@ require (
 	gonum.org/v1/plot v0.12.0 // indirect
 	google.golang.org/api v0.198.0 // indirect
 	google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
-	google.golang.org/grpc v1.66.2 // indirect
-	google.golang.org/protobuf v1.35.1 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
+	google.golang.org/grpc v1.71.0 // indirect
+	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
 	gopkg.in/sourcemap.v1 v1.0.5 // indirect
diff --git a/go.sum b/go.sum
index eff4ab9..7d07d9d 100644
--- a/go.sum
+++ b/go.sum
@@ -23,8 +23,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLM
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/compute/metadata v0.5.1 h1:NM6oZeZNlYjiwYje+sYFjEpP0Q0zCan1bmQW/KmIrGs=
-cloud.google.com/go/compute/metadata v0.5.1/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
+cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
+cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
@@ -313,8 +313,6 @@ github.com/edaniels/golog v0.0.0-20230215213219-28954395e8d0 h1:nNKMo+7J87eEtmp/
 github.com/edaniels/golog v0.0.0-20230215213219-28954395e8d0/go.mod h1:+Fw6jgiaW75M1z173j7FFZB2Ug5SccFxroA/eM2YFaM=
 github.com/edaniels/lidario v0.0.0-20220607182921-5879aa7b96dd h1:W6Zlh2ja8A5vljn17Ix/gZhaUbYMFKAuBjc4t9nma7U=
 github.com/edaniels/lidario v0.0.0-20220607182921-5879aa7b96dd/go.mod h1:CibiLtefSZOd0smxazen6pzvjNK3HrvgGojYOqC3/4Q=
-github.com/edaniels/zeroconf v1.0.10 h1:NDCpH/MLs6VHUeXFMEyl1uV2oImziAkV8UkEBXjoXcQ=
-github.com/edaniels/zeroconf v1.0.10/go.mod h1:Ug9b0xPy+iOV2Sh2sFS2RieS9litiJb90v2mhQ+MvKY=
 github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -323,8 +321,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM=
-github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4=
+github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
+github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
 github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
 github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
 github.com/esimonov/ifshort v1.0.1/go.mod h1:yZqNJUrNn20K8Q9n2CrjTKYyVEmX209Hgu+M1LBpeZE=
@@ -1054,8 +1052,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
-github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
 github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
 github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
 github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
@@ -1278,6 +1276,8 @@ github.com/viamrobotics/evdev v0.1.3 h1:mR4HFafvbc5Wx4Vp1AUJp6/aITfVx9AKyXWx+rWj
 github.com/viamrobotics/evdev v0.1.3/go.mod h1:N6nuZmPz7HEIpM7esNWwLxbYzqWqLSZkfI/1Sccckqk=
 github.com/viamrobotics/webrtc/v3 v3.99.10 h1:ykE14wm+HkqMD5Ozq4rvhzzfvnXAu14ak/HzA1OCzfY=
 github.com/viamrobotics/webrtc/v3 v3.99.10/go.mod h1:ziH7/S52IyYAeDdwUUl5ZTbuyKe47fWorAz+0z5w6NA=
+github.com/viamrobotics/zeroconf v1.0.12 h1:y0AaDnBmELvtKKzJlnRBivZ4KWYXWXyGv73SY16sXUw=
+github.com/viamrobotics/zeroconf v1.0.12/go.mod h1:6qNiiR//WWB7n2c6syvOl0rAGltrcyNW4KonC6OBkPM=
 github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
 github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
@@ -1340,24 +1340,28 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
-go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
-go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
+go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
+go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw=
 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 h1:EVSnY9JbEEW92bEkIYOVMw4q1WJxIAGoFTrtYOzWuRQ=
 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0/go.mod h1:Ea1N1QQryNXpCD0I1fdLibBAIpQuBkznMmkdKrapk1Y=
-go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
-go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
-go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
-go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
-go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
-go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
+go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
+go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
+go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
+go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
+go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
+go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
+go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
+go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
 go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
@@ -1389,14 +1393,14 @@ go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
 go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-go.viam.com/api v0.1.397 h1:BGHQ71TmGDtUOWSmY66STA8/hcbUFV+okWAtgYVK7SA=
-go.viam.com/api v0.1.397/go.mod h1:drvlBWaiHFxPziz5jayHvibez1qG7lylcNCC1LF8onU=
+go.viam.com/api v0.1.408 h1:jb0FUAUlqFOpM1A41xEZVrNbOB4CeJl3AIGTv1W3qbc=
+go.viam.com/api v0.1.408/go.mod h1:drvlBWaiHFxPziz5jayHvibez1qG7lylcNCC1LF8onU=
 go.viam.com/rdk v0.66.0 h1:veoYJjuacVeNzWGnWHvpOfr2szr22IA3uEMqJHdUfiE=
 go.viam.com/rdk v0.66.0/go.mod h1:80WmudgJ280vD0dVJaz78LaitAvWin9cw7wlT6ie9g0=
 go.viam.com/test v1.2.4 h1:JYgZhsuGAQ8sL9jWkziAXN9VJJiKbjoi9BsO33TW3ug=
 go.viam.com/test v1.2.4/go.mod h1:zI2xzosHdqXAJ/kFqcN+OIF78kQuTV2nIhGZ8EzvaJI=
-go.viam.com/utils v0.1.130 h1:5vggHNK/ar/YHXjf9z5WuihX+n9jYFnA2CEFOYi99d8=
-go.viam.com/utils v0.1.130/go.mod h1:g1CaEkf7aJCrSI/Sfkx+6cBS1+Y3fPT2pvMQbp7TTBI=
+go.viam.com/utils v0.1.137 h1:h7W9NpUIdEkWlm3OBvPTRRI2Ghx3EZ6PzN2sjVItb3A=
+go.viam.com/utils v0.1.137/go.mod h1:yvTmGprS9tgkTSXRE07GYdyD62eH5q2WJVX1TCNGzSI=
 go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 h1:WJhcL4p+YeDxmZWg141nRm7XC8IDmhz7lk5GpadO1Sg=
 go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
 gocloud.dev v0.38.0 h1:SpxfaOc/Fp4PeO8ui7wRcCZV0EgXZ+IWcVSLn6ZMSw0=
@@ -1419,8 +1423,8 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y
 golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1526,15 +1530,15 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
 golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
-golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
+golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
+golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
-golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
+golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1547,8 +1551,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1621,8 +1625,8 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1650,8 +1654,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1802,10 +1806,10 @@ google.golang.org/genproto v0.0.0-20200911024640-645f7a48b24f/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
 google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
-google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0=
-google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
+google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
+google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
@@ -1826,8 +1830,8 @@ google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTp
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
 google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
-google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
-google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v0.0.0-20200910201057-6591123024b3/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -1842,8 +1846,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.25.1-0.20200805231151-a709e31e5d12/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
-google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/milesightct101/sensor.go b/milesightct101/sensor.go
index 5bb9b6c..35fe44f 100644
--- a/milesightct101/sensor.go
+++ b/milesightct101/sensor.go
@@ -132,6 +132,9 @@ func (n *CT101) Reconfigure(ctx context.Context, deps resource.Dependencies, con
 		return err
 	}
 
+	// call this once outside of background thread to get any info gateway has before calling the interval request.
+	n.node.GetAndUpdateDeviceInfo(ctx)
+
 	// set the interval if one was provided
 	// we do not send a default in case the user has already set an interval they prefer
 	if cfg.Interval != nil && *cfg.Interval != 0 {
diff --git a/milesightem310/sensor.go b/milesightem310/sensor.go
index 5321451..59b2c43 100644
--- a/milesightem310/sensor.go
+++ b/milesightem310/sensor.go
@@ -128,6 +128,9 @@ func (n *em310Tilt) Reconfigure(ctx context.Context, deps resource.Dependencies,
 		return err
 	}
 
+	// call this once outside of background thread to get any info gateway has before calling the interval request.
+	n.node.GetAndUpdateDeviceInfo(ctx)
+
 	// set the interval if one was provided
 	// we do not send a default in case the user has already set an interval they prefer
 	if cfg.Interval != nil && *cfg.Interval != 0 {
diff --git a/node/node.go b/node/node.go
index 1374573..c314c1a 100644
--- a/node/node.go
+++ b/node/node.go
@@ -9,10 +9,12 @@ import (
 	"reflect"
 	"sync"
 
+	"github.com/viam-modules/gateway/regions"
 	"go.viam.com/rdk/components/sensor"
 	"go.viam.com/rdk/data"
 	"go.viam.com/rdk/logging"
 	"go.viam.com/rdk/resource"
+	"go.viam.com/utils"
 )
 
 // LorawanFamily is the model family for the Lorawan module.
@@ -188,6 +190,11 @@ type Node struct {
 	FCntDown  uint16
 	FPort     byte     // for downlinks, only required when frame payload exists.
 	Downlinks [][]byte // list of downlink frame payloads to send
+
+	Region             regions.Region
+	MinIntervalSeconds float64 // estimated minimum uplink interval
+
+	Workers *utils.StoppableWorkers
 }
 
 func newNode(
@@ -214,8 +221,6 @@ func newNode(
 
 // Reconfigure reconfigure's the node.
 func (n *Node) Reconfigure(ctx context.Context, deps resource.Dependencies, conf resource.Config) error {
-	n.reconfigureMu.Lock()
-	defer n.reconfigureMu.Unlock()
 	cfg, err := resource.NativeConfig[*Config](conf)
 	if err != nil {
 		return err
@@ -234,10 +239,10 @@ func (n *Node) Reconfigure(ctx context.Context, deps resource.Dependencies, conf
 	return nil
 }
 
-// getGateway sends the validate docommand to the gateway to confirm the dependency.
-func getGateway(ctx context.Context, deps resource.Dependencies) (sensor.Sensor, error) {
+// validateGateway sends the validate docommand to the gateway, and saves region and gateway to struct.
+func (n *Node) validateGateway(ctx context.Context, deps resource.Dependencies) error {
 	if len(deps) == 0 {
-		return nil, errors.New("must add sx1302-gateway as dependency")
+		return errors.New("must add sx1302-gateway as dependency")
 	}
 	var dep resource.Resource
 
@@ -249,30 +254,46 @@ func getGateway(ctx context.Context, deps resource.Dependencies) (sensor.Sensor,
 
 	gateway, ok := dep.(sensor.Sensor)
 	if !ok {
-		return nil, errors.New("dependency must be the sx1302-gateway sensor")
+		return errors.New("dependency must be the sx1302-gateway sensor")
 	}
 
 	cmd := make(map[string]interface{})
 	cmd["validate"] = 1
 
-	// Validate that the dependency is the gateway - gateway will return 1.
+	// Validate that the dependency is the gateway - gateway will return the region.
 	ret, err := gateway.DoCommand(ctx, cmd)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	retVal, ok := ret["validate"]
 	if !ok {
-		return nil, errors.New("dependency must be the sx1302-gateway sensor")
+		return errors.New("dependency must be the sx1302-gateway sensor")
+	}
+	re, ok := retVal.(float64)
+	if !ok {
+		return fmt.Errorf("expected float64 return, got %v", reflect.TypeOf(retVal))
 	}
-	if retVal.(float64) != 1 {
-		return nil, errors.New("dependency must be the sx1302-gateway sensor")
+
+	switch re {
+	case 1:
+		n.Region = regions.US
+	case 2:
+		n.Region = regions.EU
+	default:
+		return errors.New("gateway return unexpected region")
 	}
-	return gateway, nil
+
+	n.gateway = gateway
+
+	return nil
 }
 
 // Close removes the device from the gateway.
 func (n *Node) Close(ctx context.Context) error {
+	if n.Workers != nil {
+		n.Workers.Stop()
+	}
 	cmd := make(map[string]interface{})
 	cmd["remove_device"] = n.NodeName
 	_, err := n.gateway.DoCommand(ctx, cmd)
diff --git a/node/node_test.go b/node/node_test.go
index edafde3..440fae7 100644
--- a/node/node_test.go
+++ b/node/node_test.go
@@ -22,15 +22,6 @@ const (
 	// Common test values.
 	testDecoderPath = "/path/to/decoder.js"
 
-	// OTAA test values.
-	testDevEUI = "0123456789ABCDEF"
-	testAppKey = "0123456789ABCDEF0123456789ABAAAA"
-
-	// ABP test values.
-	testDevAddr = "01234567"
-	testAppSKey = "0123456789ABCDEF0123456789ABCDEE"
-	testNwkSKey = "0123456789ABCDEF0123456789ABCDEF"
-
 	// Gateway dependency.
 	testGatewayName = "gateway"
 	testNodeName    = "test-node"
@@ -51,8 +42,8 @@ func TestConfigValidate(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeOTAA,
-		DevEUI:   testDevEUI,
-		AppKey:   testAppKey,
+		DevEUI:   testutils.TestDevEUI,
+		AppKey:   testutils.TestAppKey,
 		Gateways: []string{testGatewayName},
 	}
 	deps, err := conf.Validate("")
@@ -99,7 +90,7 @@ func TestValidateOTAAAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeOTAA,
-		AppKey:   testAppKey,
+		AppKey:   testutils.TestAppKey,
 	}
 	_, err := conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrDevEUIRequired))
@@ -110,7 +101,7 @@ func TestValidateOTAAAttributes(t *testing.T) {
 		Interval: &testInterval,
 		JoinType: JoinTypeOTAA,
 		DevEUI:   "0123456", // Not 8 bytes
-		AppKey:   testAppKey,
+		AppKey:   testutils.TestAppKey,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrDevEUILength))
@@ -120,7 +111,7 @@ func TestValidateOTAAAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeOTAA,
-		DevEUI:   testDevEUI,
+		DevEUI:   testutils.TestDevEUI,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrAppKeyRequired))
@@ -130,7 +121,7 @@ func TestValidateOTAAAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeOTAA,
-		DevEUI:   testDevEUI,
+		DevEUI:   testutils.TestDevEUI,
 		AppKey:   "0123456", // Not 16 bytes
 	}
 	_, err = conf.Validate("")
@@ -141,8 +132,8 @@ func TestValidateOTAAAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeOTAA,
-		DevEUI:   testDevEUI,
-		AppKey:   testAppKey,
+		DevEUI:   testutils.TestDevEUI,
+		AppKey:   testutils.TestAppKey,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeNil)
@@ -154,8 +145,8 @@ func TestValidateABPAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeABP,
-		NwkSKey:  testNwkSKey,
-		DevAddr:  testDevAddr,
+		NwkSKey:  testutils.TestNwkSKey,
+		DevAddr:  testutils.TestDevAddr,
 	}
 	_, err := conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrAppSKeyRequired))
@@ -166,8 +157,8 @@ func TestValidateABPAttributes(t *testing.T) {
 		Interval: &testInterval,
 		JoinType: JoinTypeABP,
 		AppSKey:  "0123456", // Not 16 bytes
-		NwkSKey:  testNwkSKey,
-		DevAddr:  testDevAddr,
+		NwkSKey:  testutils.TestNwkSKey,
+		DevAddr:  testutils.TestDevAddr,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrAppSKeyLength))
@@ -177,8 +168,8 @@ func TestValidateABPAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeABP,
-		AppSKey:  testAppSKey,
-		DevAddr:  testDevAddr,
+		AppSKey:  testutils.TestAppSKey,
+		DevAddr:  testutils.TestDevAddr,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrNwkSKeyRequired))
@@ -188,9 +179,9 @@ func TestValidateABPAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeABP,
-		AppSKey:  testAppSKey,
+		AppSKey:  testutils.TestAppSKey,
 		NwkSKey:  "0123456", // Not 16 bytes
-		DevAddr:  testDevAddr,
+		DevAddr:  testutils.TestDevAddr,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrNwkSKeyLength))
@@ -200,8 +191,8 @@ func TestValidateABPAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeABP,
-		AppSKey:  testAppSKey,
-		NwkSKey:  testNwkSKey,
+		AppSKey:  testutils.TestAppSKey,
+		NwkSKey:  testutils.TestNwkSKey,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeError, resource.NewConfigValidationError("", ErrDevAddrRequired))
@@ -211,8 +202,8 @@ func TestValidateABPAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeABP,
-		AppSKey:  testAppSKey,
-		NwkSKey:  testNwkSKey,
+		AppSKey:  testutils.TestAppSKey,
+		NwkSKey:  testutils.TestNwkSKey,
 		DevAddr:  "0123", // Not 4 bytes
 	}
 	_, err = conf.Validate("")
@@ -223,9 +214,9 @@ func TestValidateABPAttributes(t *testing.T) {
 		Decoder:  testDecoderPath,
 		Interval: &testInterval,
 		JoinType: JoinTypeABP,
-		AppSKey:  testAppSKey,
-		NwkSKey:  testNwkSKey,
-		DevAddr:  testDevAddr,
+		AppSKey:  testutils.TestAppSKey,
+		NwkSKey:  testutils.TestNwkSKey,
+		DevAddr:  testutils.TestDevAddr,
 	}
 	_, err = conf.Validate("")
 	test.That(t, err, test.ShouldBeNil)
@@ -246,8 +237,8 @@ func TestNewNode(t *testing.T) {
 			Decoder:  testDecoderPath,
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -267,9 +258,9 @@ func TestNewNode(t *testing.T) {
 			Decoder:  testDecoderPath,
 			Interval: &testInterval,
 			JoinType: JoinTypeABP,
-			AppSKey:  testAppSKey,
-			NwkSKey:  testNwkSKey,
-			DevAddr:  testDevAddr,
+			AppSKey:  testutils.TestAppSKey,
+			NwkSKey:  testutils.TestNwkSKey,
+			DevAddr:  testutils.TestDevAddr,
 		},
 	}
 
@@ -278,16 +269,18 @@ func TestNewNode(t *testing.T) {
 	test.That(t, n, test.ShouldNotBeNil)
 
 	testNode = n.(*Node)
+	testNode.reconfigureMu.Lock()
+	defer testNode.reconfigureMu.Unlock()
 	test.That(t, testNode.NodeName, test.ShouldEqual, "test-node-abp")
 	test.That(t, testNode.JoinType, test.ShouldEqual, JoinTypeABP)
 	test.That(t, testNode.DecoderPath, test.ShouldEqual, testDecoderPath)
 
 	// Verify ABP byte arrays
-	expectedDevAddr, err := hex.DecodeString(testDevAddr)
+	expectedDevAddr, err := hex.DecodeString(testutils.TestDevAddr)
 	test.That(t, err, test.ShouldBeNil)
 	test.That(t, testNode.Addr, test.ShouldResemble, expectedDevAddr)
 
-	expectedAppSKey, err := hex.DecodeString(testAppSKey)
+	expectedAppSKey, err := hex.DecodeString(testutils.TestAppSKey)
 	test.That(t, err, test.ShouldBeNil)
 	test.That(t, testNode.AppSKey, test.ShouldResemble, expectedAppSKey)
 	n.Close(ctx)
@@ -299,8 +292,8 @@ func TestNewNode(t *testing.T) {
 			Decoder:  testDecoderURL,
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -322,8 +315,8 @@ func TestNewNode(t *testing.T) {
 			Decoder:  "/worong/path",
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -346,8 +339,8 @@ func TestReadings(t *testing.T) {
 			Decoder:  testDecoderPath,
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -366,8 +359,8 @@ func TestReadings(t *testing.T) {
 			Decoder:  testDecoderPath,
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -562,8 +555,8 @@ func TestDoCommand(t *testing.T) {
 			Decoder:  testDecoderPath,
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -636,8 +629,8 @@ func TestIntervalDownlink(t *testing.T) {
 			Decoder:  testDecoderPath,
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -709,12 +702,12 @@ func TestIntervalDownlink(t *testing.T) {
 		},
 		{
 			name:              "valid interval with the payload in uppercase",
-			interval:          0.5,
+			interval:          1.5,
 			payloadUnits:      Seconds,
 			numBytes:          4,
 			useLittleEndian:   false,
 			header:            "ff8e",
-			expectedReturn:    "FF8E0000001E", // 0.5 min -> 30 sec -> 0000001E  in big endian hex with 4 bytes
+			expectedReturn:    "FF8E0000005A", // 1.5 min -> 90 sec -> 0000005A  in big endian hex with 4 bytes
 			testGatewayReturn: false,
 		},
 		{
@@ -781,6 +774,18 @@ func TestIntervalDownlink(t *testing.T) {
 			testGatewayReturn: false,
 			expectedErr:       "cannot send interval downlink, interval of 256 minutes exceeds maximum number of bytes 1",
 		},
+		{
+			name:              "interval lower than min",
+			interval:          0.5,
+			payloadUnits:      Seconds,
+			numBytes:          4,
+			useLittleEndian:   false,
+			header:            "ff8e",
+			expectedReturn:    "",
+			testGatewayReturn: false,
+			expectedErr: `requested uplink interval (0.50 minutes) exceeds the legal duty cycle limit of 1.00 minutes,
+			increase the uplink interval`,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -808,8 +813,8 @@ func TestResetDownlink(t *testing.T) {
 			Decoder:  testDecoderPath,
 			Interval: &testInterval,
 			JoinType: JoinTypeOTAA,
-			DevEUI:   testDevEUI,
-			AppKey:   testAppKey,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
 		},
 	}
 
@@ -908,3 +913,110 @@ func testDoCommandResp(t *testing.T, resp map[string]interface{}, err error,
 		}
 	}
 }
+
+func TestUpdateNode(t *testing.T) {
+	n := &Node{}
+
+	validNodeInfo := map[string]interface{}{
+		"app_skey":            testutils.TestAppSKey,
+		"dev_eui":             testutils.TestDevEUI,
+		"nwk_skey":            testutils.TestNwkSKey,
+		"dev_addr":            testutils.TestDevAddr,
+		"min_uplink_interval": float64(60),
+		"fcnt_down":           float64(123),
+	}
+
+	err := n.updateNode(validNodeInfo)
+	test.That(t, err, test.ShouldBeNil)
+
+	// Verify all fields were updated correctly
+	expectedAppSKey, _ := hex.DecodeString(testutils.TestAppSKey)
+	test.That(t, n.AppSKey, test.ShouldResemble, expectedAppSKey)
+
+	expectedDevEui, _ := hex.DecodeString(testutils.TestDevEUI)
+	test.That(t, n.DevEui, test.ShouldResemble, expectedDevEui)
+
+	expectedNwkSKey, _ := hex.DecodeString(testutils.TestNwkSKey)
+	test.That(t, n.NwkSKey, test.ShouldResemble, expectedNwkSKey)
+
+	expectedAddr, _ := hex.DecodeString(testutils.TestDevAddr)
+	test.That(t, n.Addr, test.ShouldResemble, expectedAddr)
+
+	test.That(t, n.MinIntervalSeconds, test.ShouldEqual, float64(60))
+	test.That(t, n.FCntDown, test.ShouldEqual, uint16(123))
+
+	// Test invalid hex strings
+	invalidNodeInfo := map[string]interface{}{
+		"app_skey":            "invalid hex",
+		"dev_eui":             testutils.TestDevEUI,
+		"nwk_skey":            testutils.TestNwkSKey,
+		"dev_addr":            testutils.TestDevAddr,
+		"min_uplink_interval": float64(60),
+		"fcnt_down":           float64(123),
+	}
+
+	err = n.updateNode(invalidNodeInfo)
+	test.That(t, err, test.ShouldNotBeNil)
+	test.That(t, err.Error(), test.ShouldContainSubstring, "error saving app S key")
+
+	invalidNodeInfo["app_skey"] = testutils.TestAppSKey
+	invalidNodeInfo["dev_eui"] = "invalid hex"
+	err = n.updateNode(invalidNodeInfo)
+	test.That(t, err, test.ShouldNotBeNil)
+	test.That(t, err.Error(), test.ShouldContainSubstring, "error saving dev eui")
+
+	invalidNodeInfo["dev_eui"] = testutils.TestDevEUI
+	invalidNodeInfo["nwk_skey"] = "invalid hex"
+	err = n.updateNode(invalidNodeInfo)
+	test.That(t, err, test.ShouldNotBeNil)
+	test.That(t, err.Error(), test.ShouldContainSubstring, "error saving nwk S key")
+
+	invalidNodeInfo["nwk_skey"] = testutils.TestNwkSKey
+	invalidNodeInfo["dev_addr"] = "invalid hex"
+	err = n.updateNode(invalidNodeInfo)
+	test.That(t, err, test.ShouldNotBeNil)
+	test.That(t, err.Error(), test.ShouldContainSubstring, "error saving dev addr")
+}
+
+func TestPollGateway(t *testing.T) {
+	ctx := context.Background()
+	logger := logging.NewTestLogger(t)
+
+	deps, tmpDir := testutils.NewNodeTestEnv(t, gatewayNames, nodeNames, "decoder.js")
+	testDecoderPath := fmt.Sprintf("%s/%s", tmpDir, "decoder.js")
+
+	validConf := resource.Config{
+		Name: testNodeName,
+		ConvertedAttributes: &Config{
+			Decoder:  testDecoderPath,
+			Interval: &testInterval,
+			JoinType: JoinTypeOTAA,
+			DevEUI:   testutils.TestDevEUI,
+			AppKey:   testutils.TestAppKey,
+		},
+	}
+
+	n, err := newNode(ctx, deps, validConf, logger)
+	test.That(t, err, test.ShouldBeNil)
+	test.That(t, n, test.ShouldNotBeNil)
+	node := n.(*Node)
+
+	// Test successful polling
+	ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
+	defer cancel()
+
+	// Start polling in a goroutine
+	go node.PollGateway(ctx)
+
+	// Wait a bit to allow for at least one poll
+	time.Sleep(40 * time.Millisecond)
+
+	// Verify node was updated with mock gateway's response
+	node.reconfigureMu.Lock()
+	minInterval := node.MinIntervalSeconds
+	fcntDown := node.FCntDown
+	node.reconfigureMu.Unlock()
+
+	test.That(t, minInterval, test.ShouldEqual, float64(60))
+	test.That(t, fcntDown, test.ShouldEqual, uint16(1))
+}
diff --git a/node/node_utils.go b/node/node_utils.go
index 571cc9a..2cef6ea 100644
--- a/node/node_utils.go
+++ b/node/node_utils.go
@@ -15,15 +15,22 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"reflect"
 	"strings"
 	"time"
 
+	"github.com/viam-modules/gateway/regions"
 	"go.viam.com/rdk/logging"
 	"go.viam.com/rdk/resource"
+	"go.viam.com/utils"
 )
 
-// TestKey is a DoCommand key to skip sending commands downstream to the generic node and/or gateway.
-const TestKey = "test_only"
+const (
+	// TestKey is a DoCommand key to skip sending commands downstream to the generic node and/or gateway.
+	TestKey = "test_only"
+	// GetDeviceKey is a DoCommand key to get device info from a device.
+	GetDeviceKey = "get_device"
+)
 
 // NewSensor creates a new Node struct. This can be used by external implementers.
 func NewSensor(conf resource.Config, logger logging.Logger) Node {
@@ -37,6 +44,8 @@ func NewSensor(conf resource.Config, logger logging.Logger) Node {
 // ReconfigureWithConfig runs the reconfigure logic of a Node using the native Node Config.
 // For Specialized sensor implementations this function should be used within Reconfigure.
 func (n *Node) ReconfigureWithConfig(ctx context.Context, deps resource.Dependencies, cfg *Config) error {
+	n.reconfigureMu.Lock()
+	defer n.reconfigureMu.Unlock()
 	switch cfg.JoinType {
 	case "OTAA", "":
 		appKey, err := hex.DecodeString(cfg.AppKey)
@@ -84,6 +93,7 @@ func (n *Node) ReconfigureWithConfig(ctx context.Context, deps resource.Dependen
 		if err != nil {
 			return err
 		}
+
 		n.DecoderPath = decoderFilePath
 	} else if err := isValidFilePath(n.DecoderPath); err != nil {
 		return fmt.Errorf("provided decoder file path is not valid: %w", err)
@@ -102,7 +112,7 @@ func (n *Node) ReconfigureWithConfig(ctx context.Context, deps resource.Dependen
 		n.FPort = val[0]
 	}
 
-	gateway, err := getGateway(ctx, deps)
+	err := n.validateGateway(ctx, deps)
 	if err != nil {
 		return err
 	}
@@ -112,12 +122,16 @@ func (n *Node) ReconfigureWithConfig(ctx context.Context, deps resource.Dependen
 	// send the device to the gateway.
 	cmd["register_device"] = n
 
-	_, err = gateway.DoCommand(ctx, cmd)
+	_, err = n.gateway.DoCommand(ctx, cmd)
 	if err != nil {
 		return err
 	}
 
-	n.gateway = gateway
+	// Start the background routine only if it hasn't been started previously.
+	if n.Workers == nil {
+		n.Workers = utils.NewBackgroundStoppableWorkers(n.PollGateway)
+	}
+
 	return nil
 }
 
@@ -344,6 +358,22 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	// Lock to prevent minInterval from updating during read.
+	n.reconfigureMu.Lock()
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > (req.IntervalMin * 60.0) {
+			n.reconfigureMu.Unlock()
+			return nil, fmt.Errorf(`requested uplink interval (%.2f minutes) exceeds the legal duty cycle limit of %.2f minutes,
+			increase the uplink interval`, req.IntervalMin, n.MinIntervalSeconds/60.0)
+		}
+	}
+	n.reconfigureMu.Unlock()
+
 	// we format the hex with uppercase, ensure the header is too.
 	intervalString := strings.ToUpper(req.Header)
 	if req.UseLittleEndian {
@@ -363,6 +393,7 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 	if req.TestOnly {
 		return map[string]interface{}{IntervalKey: intervalString}, nil
 	}
+
 	return n.SendDownlink(ctx, intervalString, false)
 }
 
@@ -397,3 +428,77 @@ func (n *Node) SendResetDownlink(ctx context.Context, req ResetRequest) (map[str
 
 	return n.SendDownlink(ctx, fullPayload, false)
 }
+
+// PollGateway sends a do command to the gateway every 30 seconds to updated new device info.
+func (n *Node) PollGateway(ctx context.Context) {
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+		n.GetAndUpdateDeviceInfo(ctx)
+		// wait 30 seconds between checking for updates
+		utils.SelectContextOrWait(ctx, time.Second*30)
+	}
+}
+
+// updateNode converts the map from the docommand into the node struct.
+func (n *Node) updateNode(mapNode map[string]interface{}) error {
+	// Parse all values before acquiring lock
+	appSKey, err := hex.DecodeString(mapNode["app_skey"].(string))
+	if err != nil {
+		return fmt.Errorf("error saving app S key: %w", err)
+	}
+
+	devEui, err := hex.DecodeString(mapNode["dev_eui"].(string))
+	if err != nil {
+		return fmt.Errorf("error saving dev eui: %w", err)
+	}
+
+	nwkSKey, err := hex.DecodeString(mapNode["nwk_skey"].(string))
+	if err != nil {
+		return fmt.Errorf("error saving nwk S key: %w", err)
+	}
+
+	addr, err := hex.DecodeString(mapNode["dev_addr"].(string))
+	if err != nil {
+		return fmt.Errorf("error saving dev addr: %w", err)
+	}
+
+	minInterval := mapNode["min_uplink_interval"].(float64)
+	fcntDown := uint16(mapNode["fcnt_down"].(float64))
+
+	n.AppSKey = appSKey
+	n.DevEui = devEui
+	n.NwkSKey = nwkSKey
+	n.Addr = addr
+	n.MinIntervalSeconds = minInterval
+	n.FCntDown = fcntDown
+
+	return nil
+}
+
+// GetAndUpdateDeviceInfo sends the docommand to gateway to receive device info, and saves info to the struct.
+func (n *Node) GetAndUpdateDeviceInfo(ctx context.Context) {
+	n.reconfigureMu.Lock()
+	defer n.reconfigureMu.Unlock()
+	eui := hex.EncodeToString(n.DevEui)
+	cmd := map[string]interface{}{GetDeviceKey: eui}
+	resp, err := n.gateway.DoCommand(ctx, cmd)
+	if err != nil {
+		n.logger.Errorf("error getting node info: %v", err.Error())
+	}
+	if device, ok := resp[GetDeviceKey]; ok {
+		if device != nil {
+			dev, ok := device.(map[string]interface{})
+			if !ok {
+				n.logger.Errorf("expected a float64 but got %v", reflect.TypeOf(device))
+			}
+			// update node struct with info
+			if err := n.updateNode(dev); err != nil {
+				n.logger.Errorf("error getting device info: %w", err)
+			}
+		}
+	}
+}
diff --git a/testutils/node_test_utils.go b/testutils/node_test_utils.go
index a7fa133..4e491ba 100644
--- a/testutils/node_test_utils.go
+++ b/testutils/node_test_utils.go
@@ -14,6 +14,19 @@ import (
 	"go.viam.com/test"
 )
 
+const (
+	// TestDevEUI is a fake deveui for tests.
+	TestDevEUI = "0123456789ABCDEF"
+	// TestAppKey is fake app key for tests.
+	TestAppKey = "0123456789ABCDEF0123456789ABAAAA"
+	// TestDevAddr is fake dev addr for tests.
+	TestDevAddr = "01234567"
+	// TestAppSKey is fake appskey for tests.
+	TestAppSKey = "0123456789ABCDEF0123456789ABCDEE"
+	// TestNwkSKey is fake nwkskey for tests.
+	TestNwkSKey = "0123456789ABCDEF0123456789ABCDEF"
+)
+
 const gatewaySendDownlinkKey = "add_downlink_to_queue"
 
 func createMockGateway(devices []string) *inject.Sensor {
@@ -25,8 +38,18 @@ func createMockGateway(devices []string) *inject.Sensor {
 		if _, ok := cmd[gatewaySendDownlinkKey]; ok {
 			return map[string]interface{}{gatewaySendDownlinkKey: "downlink added"}, nil
 		}
+		if _, ok := cmd["get_device"]; ok {
+			resp := map[string]interface{}{}
+			resp["get_device"] = map[string]interface{}{
+				"app_skey": TestAppSKey,
+				"nwk_skey": TestNwkSKey, "dev_eui": TestDevEUI, "min_uplink_interval": 60.0,
+				"fcnt_down": 1.0, "dev_addr": TestDevAddr,
+			}
+			return resp, nil
+		}
 		return map[string]interface{}{}, nil
 	}
+
 	testNodeReadings := map[string]interface{}{"reading": 1}
 
 	mockGateway.ReadingsFunc = func(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {


Reviews:
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T20:47:50Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T20:56:22Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:05:35Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:13:02Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:13:59Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:14:26Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:15:22Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:17:56Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:20:16Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:23:50Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:26:01Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:26:21Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:27:30Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:31:43Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:46:47Z
user: login: randhid
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-01T14:46:15Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T20:39:37Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T20:40:34Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T20:51:27Z
user: login: JohnN193
body: Main concern is setting up the background workers in the separate nodes. not sure the right way to handle that
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T21:27:59Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T21:39:53Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T21:56:02Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T21:58:14Z
user: login: randhid
body: Have we verified the following through manual testing:
1. No hang on viam server after a reconfigure
2. No hang on viam server after a module restart.
3. Successful reconfiguration of any of our nodes.
4. Successful reconfiguration with data capture enabled - this shows that the code is functional even with a concurrent call to Readings as a reconfigure is called.
5. Successful restart with data capture enabled.
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T18:50:29Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T18:59:40Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T19:03:30Z
user: login: randhid
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T19:16:02Z
user: login: JohnN193
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T19:19:52Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T19:22:04Z
user: login: JohnN193
body: LGTM once the thread start is resolved. 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-03T19:37:22Z
user: login: oliviamiller
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T19:46:41Z
user: login: randhid
state: APPROVED
author_association: NONE
submitted_at: 2025-04-03T22:15:14Z

Review comments:
diff_hunk: @@ -241,3 +249,12 @@ func createLinkCheckAns(snr float64, sf int) []byte {
 
 	return payload
 }
+
+// EU devices have limitations on how often they can use the frequency band.
+// duty cycle = 1/2^(MaxDCycle).
+func createDutyCycleReq() []byte {
+	payload := make([]byte, 0)
+	payload = append(payload, dutyCycleCID)
+	payload = append(payload, 0x07) // 2^-7 = 0.78% duty cyle, closest to 1% we can get.
path: gateway/downlink.go
user: login: oliviamiller
body: This request is very limited with the duty cycles it can request, the closest we can get to set a 1% duty cycle is 0.78%. 
created_at: 2025-03-31T20:47:50Z
author_association: COLLABORATOR
diff_hunk: @@ -10,22 +10,20 @@ import (
 
 	"github.com/viam-modules/gateway/lorahw"
 	"github.com/viam-modules/gateway/node"
+	"github.com/viam-modules/gateway/regions"
 	"go.thethings.network/lorawan-stack/v3/pkg/crypto"
 	"go.thethings.network/lorawan-stack/v3/pkg/types"
 	"go.viam.com/utils"
 )
 
 const (
-	joinDelay      = 6         // rx2 delay in seconds for sending join accept message.
-	downlinkDelay  = 2         // rx2 delay in seconds for downlink messages.
-	rx2FrequencyUS = 923300000 // rx2 default freq for US
-	rx2FrequencyEU = 869525000 // rx2 default freq for EU region
path: gateway/downlink.go
user: login: oliviamiller
body: missed removing these in my last PR, they were moved to regions package 
created_at: 2025-03-31T20:56:22Z
author_association: COLLABORATOR
diff_hunk: @@ -265,9 +269,20 @@ func getGateway(ctx context.Context, deps resource.Dependencies) (sensor.Sensor,
 	if !ok {
 		return nil, errors.New("dependency must be the sx1302-gateway sensor")
 	}
-	if retVal.(float64) != 1 {
-		return nil, errors.New("dependency must be the sx1302-gateway sensor")
+	re, ok := retVal.(float64)
path: node/node.go
user: login: JohnN193
body: could this be retVal.(regions.Region)? no idea if that works
created_at: 2025-03-31T21:05:34Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: JohnN193
body: where does this field get set?
created_at: 2025-03-31T21:13:02Z
author_association: COLLABORATOR
diff_hunk: @@ -241,3 +249,12 @@ func createLinkCheckAns(snr float64, sf int) []byte {
 
 	return payload
 }
+
+// EU devices have limitations on how often they can use the frequency band.
+// duty cycle = 1/2^(MaxDCycle).
+func createDutyCycleReq() []byte {
+	payload := make([]byte, 0)
+	payload = append(payload, dutyCycleCID)
+	payload = append(payload, 0x07) // 2^-7 = 0.78% duty cyle, closest to 1% we can get.
path: gateway/downlink.go
user: login: JohnN193
body: thanks i hate it
created_at: 2025-03-31T21:13:59Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: oliviamiller
body: `parseDataUplink` line 108
created_at: 2025-03-31T21:14:26Z
author_association: COLLABORATOR
diff_hunk: @@ -265,9 +269,20 @@ func getGateway(ctx context.Context, deps resource.Dependencies) (sensor.Sensor,
 	if !ok {
 		return nil, errors.New("dependency must be the sx1302-gateway sensor")
 	}
-	if retVal.(float64) != 1 {
-		return nil, errors.New("dependency must be the sx1302-gateway sensor")
+	re, ok := retVal.(float64)
path: node/node.go
user: login: oliviamiller
body: it throws an error, I think going through grpc converts it to a float 
created_at: 2025-03-31T21:15:22Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: JohnN193
body: wait so a pointer thru a DoCommand can make a change to a field with a different component?
created_at: 2025-03-31T21:17:56Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: oliviamiller
body: not sure I understand what you mean, the docommand here isn't changing any fields, but the gateway is setting this field for the device in parseDataUplink. 
created_at: 2025-03-31T21:20:16Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: oliviamiller
body: ah wait yeah this wouldn't work, sorry got confused 
created_at: 2025-03-31T21:23:50Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: JohnN193
body: parseDataUplink happens in the receive thread of `gateway.go`, in gateway modules. 

SendIntervalDownlink is called by nodes, via their `DoCommand` or node `reconfigure`

so the only way for parseDataUplink to actually set the field is if the pointer/struct we send during `register_device` points back to the node

if it does work does it also mean

- the node should have access to the apps/nwks keys
- the another gateway would also have access to those keys, via the same pointers
- our setup has more threads that can access information than we thought
created_at: 2025-03-31T21:26:01Z
author_association: COLLABORATOR
diff_hunk: @@ -265,9 +269,20 @@ func getGateway(ctx context.Context, deps resource.Dependencies) (sensor.Sensor,
 	if !ok {
 		return nil, errors.New("dependency must be the sx1302-gateway sensor")
 	}
-	if retVal.(float64) != 1 {
-		return nil, errors.New("dependency must be the sx1302-gateway sensor")
+	re, ok := retVal.(float64)
path: node/node.go
user: login: JohnN193
body: kk was worth a shot
created_at: 2025-03-31T21:26:21Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: JohnN193
body: o i refreshed & u commented

also for testing lets go ahead and enforce the EU requirement on our NA setups. we can remove it before merging
created_at: 2025-03-31T21:27:30Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: oliviamiller
body: I tried to do that but sending the dutyCycleReq to the US device caused it to start resending join requests, im guessing it doesn't expect to get that command and freaks out 
created_at: 2025-03-31T21:31:43Z
author_association: COLLABORATOR
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: JohnN193
body: ah kk. then lets ping felix to see if we can test with his setup
created_at: 2025-03-31T21:46:47Z
author_association: COLLABORATOR
diff_hunk: @@ -294,3 +304,39 @@ func (g *gateway) getFOptsToSend(fopts []byte, device *node.Node) []byte {
 	}
 	return requests
 }
+
+// Calculate an estimated minimum uplink interval based on the sf and size of the uplink.
+// Formula for TOA can be found in semtech's sx1262 datasheet 6.1.4:
+// https://semtech.my.salesforce.com/sfc/p/#E0000000JelG/a/2R000000Un7F/yT.fKdAr9ZAo3cJLc4F2cBdUsMftpT2vsOICP7NmvMo
+func calculateMinUplinkInterval(sf, size int) float64 {
+	bw := 125000.0
+	dutyCycle := 0.0078125      // 0.78% duty cycle
+	preambleSymbols := 8        // symbols in lora packet preamble
+	overheadSymbols := 4.25     // other symbols in lora packet
+	syncWordSymbols := 8        // overhead for sync word
+	explicitHeaderSymbols := 20 // lora explicit header
+	codeRate := 1               // lora packets using 4/5 coderate so use 1
+	crcNumBits := 16            // num bits in crc
+
+	payloadBits := 8 * size // get the number of bits of the payload
+	payloadBits += crcNumBits
+	payloadBits -= 4 * (sf) // Unclear what the -4 does, not explained in datasheet.
+	payloadBits += 8        // Mystery number from datasheet formula
+	payloadBits += explicitHeaderSymbols
+
+	bitsPerSymbol := float64(sf)
path: gateway/uplink.go
user: login: randhid
body: Can this ever be 0?
created_at: 2025-04-01T14:15:59Z
author_association: NONE
diff_hunk: @@ -475,9 +480,9 @@ func (g *gateway) updateReadings(name string, newReadings map[string]interface{}
 func (g *gateway) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {
 	g.mu.Lock()
 	defer g.mu.Unlock()
-	// Validate that the dependency is correct.
+	// Validate that the dependency is correct, returns the gateway's region.
 	if _, ok := cmd["validate"]; ok {
-		return map[string]interface{}{"validate": 1}, nil
+		return map[string]interface{}{"validate": g.region}, nil
path: gateway/gateway.go
user: login: randhid
body: This is kinda nice.
created_at: 2025-04-01T14:22:32Z
author_association: NONE
diff_hunk: @@ -241,3 +249,12 @@ func createLinkCheckAns(snr float64, sf int) []byte {
 
 	return payload
 }
+
+// EU devices have limitations on how often they can use the frequency band.
+// duty cycle = 1/2^(MaxDCycle).
+func createDutyCycleReq() []byte {
+	payload := make([]byte, 0)
+	payload = append(payload, dutyCycleCID)
+	payload = append(payload, 0x07) // 2^-7 = 0.78% duty cyle, closest to 1% we can get.
path: gateway/downlink.go
user: login: randhid
body: This sucks.
created_at: 2025-04-01T14:37:05Z
author_association: NONE
diff_hunk: @@ -187,22 +209,27 @@ func TestCreateDownlink(t *testing.T) {
 				test.That(t, payload[5], test.ShouldEqual, tt.expectedFctrl)
 
 				currentPos := 8 // Start after MHDR(1) + DevAddr(4) + FCtrl(1) + FCnt(2)
-				if tt.uplinkFopts != nil {
+				if tt.expectedFOptsLength != 0 {
+					fOpts := payload[8 : 8+tt.expectedFOptsLength]
 					for _, b := range tt.uplinkFopts {
 						if b == deviceTimeCID {
-							test.That(t, payload[currentPos], test.ShouldEqual, deviceTimeCID)
+							test.That(t, fOpts, test.ShouldContain, uint8(deviceTimeCID))
path: gateway/downlink_test.go
user: login: randhid
body: Is there a case where the `fOpts` contains the uint8 we're searching for but in the wrong position in the fOpts array?
created_at: 2025-04-01T14:38:40Z
author_association: NONE
diff_hunk: @@ -345,6 +346,18 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > req.IntervalMin*60 {
+			n.logger.Warnf(`requested uplink interval (%.2f minutes) may exceed the legal duty cycle limit.
+			The device may not transmit at this interval`, req.IntervalMin)
+		}
+	}
path: node/node_utils.go
user: login: randhid
body: I'd suggest making an rc with just this code and release it to him to test out any issues. Does that sound like a good plan?
created_at: 2025-04-01T14:46:13Z
author_association: NONE
diff_hunk: @@ -294,3 +304,39 @@ func (g *gateway) getFOptsToSend(fopts []byte, device *node.Node) []byte {
 	}
 	return requests
 }
+
+// Calculate an estimated minimum uplink interval based on the sf and size of the uplink.
+// Formula for TOA can be found in semtech's sx1262 datasheet 6.1.4:
+// https://semtech.my.salesforce.com/sfc/p/#E0000000JelG/a/2R000000Un7F/yT.fKdAr9ZAo3cJLc4F2cBdUsMftpT2vsOICP7NmvMo
+func calculateMinUplinkInterval(sf, size int) float64 {
+	bw := 125000.0
+	dutyCycle := 0.0078125      // 0.78% duty cycle
+	preambleSymbols := 8        // symbols in lora packet preamble
+	overheadSymbols := 4.25     // other symbols in lora packet
+	syncWordSymbols := 8        // overhead for sync word
+	explicitHeaderSymbols := 20 // lora explicit header
+	codeRate := 1               // lora packets using 4/5 coderate so use 1
+	crcNumBits := 16            // num bits in crc
+
+	payloadBits := 8 * size // get the number of bits of the payload
+	payloadBits += crcNumBits
+	payloadBits -= 4 * (sf) // Unclear what the -4 does, not explained in datasheet.
+	payloadBits += 8        // Mystery number from datasheet formula
+	payloadBits += explicitHeaderSymbols
+
+	bitsPerSymbol := float64(sf)
path: gateway/uplink.go
user: login: oliviamiller
body: no, the spread factor sent by the device will be 4-12
created_at: 2025-04-02T20:39:37Z
author_association: COLLABORATOR
diff_hunk: @@ -187,22 +209,27 @@ func TestCreateDownlink(t *testing.T) {
 				test.That(t, payload[5], test.ShouldEqual, tt.expectedFctrl)
 
 				currentPos := 8 // Start after MHDR(1) + DevAddr(4) + FCtrl(1) + FCnt(2)
-				if tt.uplinkFopts != nil {
+				if tt.expectedFOptsLength != 0 {
+					fOpts := payload[8 : 8+tt.expectedFOptsLength]
 					for _, b := range tt.uplinkFopts {
 						if b == deviceTimeCID {
-							test.That(t, payload[currentPos], test.ShouldEqual, deviceTimeCID)
+							test.That(t, fOpts, test.ShouldContain, uint8(deviceTimeCID))
path: gateway/downlink_test.go
user: login: oliviamiller
body: yea that would be possible, updated the test to check for the right position
created_at: 2025-04-02T20:40:34Z
author_association: COLLABORATOR
diff_hunk: @@ -107,6 +108,8 @@ func newLHT65N(
 		return nil, err
 	}
 
+	n.node.Workers = utils.NewBackgroundStoppableWorkers(n.node.PollGateway)
path: draginolht65n/sensor.go
user: login: JohnN193
body: can this live in the generic node code? potentially in reconfigure since the gateways we talk to can change
created_at: 2025-04-02T20:51:27Z
author_association: COLLABORATOR
diff_hunk: @@ -98,7 +99,31 @@ func (g *gateway) parseDataUplink(ctx context.Context, phyPayload []byte, packet
 		device.Downlinks = device.Downlinks[1:]
 	}
 
-	if downlinkPayload != nil || len(requests) > 0 || sendAck {
+	// set the duty cycle in first downlink if in EU region.
+	setDutyCycle := false
+	if device.Region == regions.EU && device.FCntDown == 0 {
+		setDutyCycle = true
+		minInterval := calculateMinUplinkInterval(sf, len(phyPayload))
+		g.logger.Warnf("Duty cycle limit on EU868 band is 1%%, minimum uplink interval is around %.1f seconds", minInterval)
+
+		// create new DeviceInfo to update the minimum interval in the file.
+		deviceInfo := deviceInfo{
+			DevEUI:            fmt.Sprintf("%X", device.DevEui),
+			DevAddr:           fmt.Sprintf("%X", device.Addr),
+			AppSKey:           fmt.Sprintf("%X", device.AppSKey),
+			NwkSKey:           fmt.Sprintf("%X", device.NwkSKey),
+			FCntDown:          &device.FCntDown,
+			NodeName:          device.NodeName,
+			MinUplinkInterval: minInterval,
+		}
+		ctxTimeout, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
+		defer cancel()
+		if err = g.insertOrUpdateDeviceInDB(ctxTimeout, deviceInfo); err != nil {
+			return "", map[string]interface{}{}, fmt.Errorf("failed to update device info in db: %w", err)
+		}
path: gateway/uplink.go
user: login: JohnN193
body: going to change this setup to not require the entire deviceInfo to update a field. sorry you gotta do this right now!
created_at: 2025-04-02T20:58:19Z
author_association: COLLABORATOR
diff_hunk: @@ -98,7 +99,31 @@ func (g *gateway) parseDataUplink(ctx context.Context, phyPayload []byte, packet
 		device.Downlinks = device.Downlinks[1:]
 	}
 
-	if downlinkPayload != nil || len(requests) > 0 || sendAck {
+	// set the duty cycle in first downlink if in EU region.
+	setDutyCycle := false
+	if device.Region == regions.EU && device.FCntDown == 0 {
+		setDutyCycle = true
+		minInterval := calculateMinUplinkInterval(sf, len(phyPayload))
path: gateway/uplink.go
user: login: JohnN193
body: [q/opt] should we future proof this by doing something like 
```suggestion
	if device.Region != regions.NA && device.FCntDown == 0 {
		setDutyCycle = true
		minInterval := calculateMinUplinkInterval(sf, len(phyPayload), device.Region)
```

very fine with the answer being no
created_at: 2025-04-02T21:01:33Z
author_association: COLLABORATOR
diff_hunk: @@ -98,7 +99,31 @@ func (g *gateway) parseDataUplink(ctx context.Context, phyPayload []byte, packet
 		device.Downlinks = device.Downlinks[1:]
 	}
 
-	if downlinkPayload != nil || len(requests) > 0 || sendAck {
+	// set the duty cycle in first downlink if in EU region.
+	setDutyCycle := false
+	if device.Region == regions.EU && device.FCntDown == 0 {
path: gateway/uplink.go
user: login: JohnN193
body: should this be the gateway's region?
created_at: 2025-04-02T21:02:26Z
author_association: COLLABORATOR
diff_hunk: @@ -127,15 +130,17 @@ func (n *em310Tilt) Reconfigure(ctx context.Context, deps resource.Dependencies,
 	if err = n.node.ReconfigureWithConfig(ctx, deps, &nodeCfg); err != nil {
 		return err
 	}
-
+	n.logger.Infof("HERE")
path: milesightem310/sensor.go
user: login: JohnN193
body: ```suggestion
```
created_at: 2025-04-02T21:04:05Z
author_association: COLLABORATOR
diff_hunk: @@ -127,15 +130,17 @@ func (n *em310Tilt) Reconfigure(ctx context.Context, deps resource.Dependencies,
 	if err = n.node.ReconfigureWithConfig(ctx, deps, &nodeCfg); err != nil {
 		return err
 	}
-
+	n.logger.Infof("HERE")
 	// set the interval if one was provided
 	// we do not send a default in case the user has already set an interval they prefer
 	if cfg.Interval != nil && *cfg.Interval != 0 {
+		n.logger.Infof("HERE cfg interval change")
path: milesightem310/sensor.go
user: login: JohnN193
body: ```suggestion
```
created_at: 2025-04-02T21:04:15Z
author_association: COLLABORATOR
diff_hunk: @@ -127,15 +130,17 @@ func (n *em310Tilt) Reconfigure(ctx context.Context, deps resource.Dependencies,
 	if err = n.node.ReconfigureWithConfig(ctx, deps, &nodeCfg); err != nil {
 		return err
 	}
-
+	n.logger.Infof("HERE")
 	// set the interval if one was provided
 	// we do not send a default in case the user has already set an interval they prefer
 	if cfg.Interval != nil && *cfg.Interval != 0 {
+		n.logger.Infof("HERE cfg interval change")
 		req := node.IntervalRequest{
 			IntervalMin: *nodeCfg.Interval, PayloadUnits: node.Seconds, Header: "ff03",
 			UseLittleEndian: true, NumBytes: 2, TestOnly: false,
 		}
 		if _, err := n.node.SendIntervalDownlink(ctx, req); err != nil {
+			n.logger.Infof("HERE ERROR RECONFIG")
path: milesightem310/sensor.go
user: login: JohnN193
body: ```suggestion
```
created_at: 2025-04-02T21:04:24Z
author_association: COLLABORATOR
diff_hunk: @@ -144,6 +149,8 @@ func (n *em310Tilt) Reconfigure(ctx context.Context, deps resource.Dependencies,
 		return err
 	}
 
+	n.logger.Infof("no erro reconfig")
path: milesightem310/sensor.go
user: login: JohnN193
body: ```suggestion
```
created_at: 2025-04-02T21:04:36Z
author_association: COLLABORATOR
diff_hunk: @@ -344,6 +352,21 @@ func (n *Node) SendIntervalDownlink(ctx context.Context, req IntervalRequest) (m
 			req.IntervalMin, req.NumBytes)
 	}
 
+	if n.Region == regions.EU {
+		n.logger.Warnf(`the duty cycle limit in the EU region is 1%%. Ensure your
+		 uplink interval complies with this restriction to avoid transmission issues`)
+	}
+
+	// Lock to prevent minInterval from updating during read.
+	n.reconfigureMu.Lock()
+	if n.MinIntervalSeconds != 0 {
+		if n.MinIntervalSeconds > (req.IntervalMin * 60.0) {
+			return nil, fmt.Errorf(`requested uplink interval (%.2f minutes) exceeds the legal duty cycle limit of %.2f,
+			increase the uplink interval`, req.IntervalMin, n.MinIntervalSeconds/60.0)
+		}
+	}
path: node/node_utils.go
user: login: JohnN193
body: [note to self] so we can't prevent the initial downlink that gets sent, but subsequent downlinks will be validated. 

The gateway will throw an error before this if a node is misconfigured, so that should be fine(i think?)
created_at: 2025-04-02T21:16:42Z
author_association: COLLABORATOR
diff_hunk: @@ -189,7 +218,8 @@ func TestCreateDownlink(t *testing.T) {
 				test.That(t, payload[5], test.ShouldEqual, tt.expectedFctrl)
 
 				currentPos := 8 // Start after MHDR(1) + DevAddr(4) + FCtrl(1) + FCnt(2)
-				if tt.uplinkFopts != nil {
+				if tt.expectedFOptsLength != 0 {
+					//fOpts := payload[8 : 8+tt.expectedFOptsLength]
path: gateway/downlink_test.go
user: login: JohnN193
body: ```suggestion
```
created_at: 2025-04-02T21:18:26Z
author_association: COLLABORATOR
diff_hunk: @@ -397,3 +421,72 @@ func (n *Node) SendResetDownlink(ctx context.Context, req ResetRequest) (map[str
 
 	return n.SendDownlink(ctx, fullPayload, false)
 }
+
+// PollGateway sends a do command to the gateway every 30 seconds to updated new device info.
+func (n *Node) PollGateway(ctx context.Context) {
+	eui := hex.EncodeToString(n.DevEui)
+	cmd := map[string]interface{}{GetDeviceKey: eui}
path: node/node_utils.go
user: login: JohnN193
body: [opt] store a copy of the response here so we can validate that something has changed before locking/updating the struct.
created_at: 2025-04-02T21:22:26Z
author_association: COLLABORATOR
diff_hunk: @@ -235,7 +242,7 @@ func (n *Node) Reconfigure(ctx context.Context, deps resource.Dependencies, conf
 }
 
 // getGateway sends the validate docommand to the gateway to confirm the dependency.
-func getGateway(ctx context.Context, deps resource.Dependencies) (sensor.Sensor, error) {
+func (n *Node) getGateway(ctx context.Context, deps resource.Dependencies) (sensor.Sensor, error) {
path: node/node.go
user: login: JohnN193
body: either we should return both the region and the gateway, or store them both on the struct within this function
created_at: 2025-04-02T21:24:56Z
author_association: COLLABORATOR
diff_hunk: @@ -107,6 +108,8 @@ func newLHT65N(
 		return nil, err
 	}
 
+	n.node.Workers = utils.NewBackgroundStoppableWorkers(n.node.PollGateway)
path: draginolht65n/sensor.go
user: login: JohnN193
body: we can have this live in reconfigure, then if we detect the gateway has changed we stop the worker and restart the thread. 

main worry is that they both use the same mutex, so I don't know how we are supposed to cancel the background worker while we hold the mutex in reconfigure
created_at: 2025-04-02T21:27:19Z
author_association: COLLABORATOR
diff_hunk: @@ -98,7 +99,31 @@ func (g *gateway) parseDataUplink(ctx context.Context, phyPayload []byte, packet
 		device.Downlinks = device.Downlinks[1:]
 	}
 
-	if downlinkPayload != nil || len(requests) > 0 || sendAck {
+	// set the duty cycle in first downlink if in EU region.
+	setDutyCycle := false
+	if device.Region == regions.EU && device.FCntDown == 0 {
path: gateway/uplink.go
user: login: oliviamiller
body: they should always match so we could use either, I feel like its better to use the region of the device we're receiving the uplink from
created_at: 2025-04-02T21:39:52Z
author_association: COLLABORATOR
diff_hunk: @@ -98,7 +99,31 @@ func (g *gateway) parseDataUplink(ctx context.Context, phyPayload []byte, packet
 		device.Downlinks = device.Downlinks[1:]
 	}
 
-	if downlinkPayload != nil || len(requests) > 0 || sendAck {
+	// set the duty cycle in first downlink if in EU region.
+	setDutyCycle := false
+	if device.Region == regions.EU && device.FCntDown == 0 {
path: gateway/uplink.go
user: login: JohnN193
body: yeah i guess thats kinda my point, this field isn't from the node. the `device` here is just from the gateway struct, which should always match the gateway. 

when we eventually configure multiple gateways this is could be even more of an issue, as the two gateways could have different regions configured for whatever reason. 
created_at: 2025-04-02T21:56:02Z
author_association: COLLABORATOR
diff_hunk: @@ -98,7 +99,31 @@ func (g *gateway) parseDataUplink(ctx context.Context, phyPayload []byte, packet
 		device.Downlinks = device.Downlinks[1:]
 	}
 
-	if downlinkPayload != nil || len(requests) > 0 || sendAck {
+	// set the duty cycle in first downlink if in EU region.
+	setDutyCycle := false
+	if device.Region == regions.EU && device.FCntDown == 0 {
path: gateway/uplink.go
user: login: oliviamiller
body: fair point, I can change it to gateway 
created_at: 2025-04-02T21:58:14Z
author_association: COLLABORATOR
diff_hunk: @@ -107,6 +108,8 @@ func newLHT65N(
 		return nil, err
 	}
 
+	n.node.Workers = utils.NewBackgroundStoppableWorkers(n.node.PollGateway)
path: draginolht65n/sensor.go
user: login: randhid
body: Yes, this is a good suggestion to DRY up the code.

In an ideal world, we'd want the specific node model code to all look the same with inputs to make the specific nodes - decoder paths, known downlink, etc.

Think about how genericlinux is used to make all our boards as an example - the modules consist of one function call and one data file.
created_at: 2025-04-03T15:20:32Z
author_association: NONE
diff_hunk: @@ -89,7 +89,8 @@ func accurateSleep(ctx context.Context, duration time.Duration) bool {
 // Downlink payload structure
 // | MHDR | DEV ADDR | FCTRL | FCNTDOWN |  FOPTS (optional)  |  FPORT | encrypted frame payload  |  MIC |
 // | 1 B  |   4 B    |  1 B  |    2 B   |       variable     |   1 B  |      variable            | 4 B  |.
-func (g *gateway) createDownlink(device *node.Node, framePayload, uplinkFopts []byte, sendAck bool,
+func (g *gateway) createDownlink(ctx context.Context, device *node.Node, framePayload, uplinkFopts []byte,
+	sendAck bool,
path: gateway/downlink.go
user: login: randhid
body: ```suggestion
func (g *gateway) createDownlink(
        ctx context.Context, 
        device *node.Node, 
        framePayload, uplinkFopts []byte,
	sendAck bool,
```
created_at: 2025-04-03T15:21:49Z
author_association: NONE
diff_hunk: @@ -172,13 +172,11 @@ type gateway struct {
 	rstPin  board.GPIOPin
 	pwrPin  board.GPIOPin
 
-	logReader *os.File
-	logWriter *os.File
-	dataFile  *os.File
-	dataMu    sync.Mutex
-
-	region     regions.Region
+	logReader  *os.File
+	logWriter  *os.File
+	db         *sql.DB // store device information/keys for use across restarts in a database
path: gateway/gateway.go
user: login: randhid
body: Is I would not have expected a diff here in your code - can you check fro merge conflicts in this file?
created_at: 2025-04-03T15:22:59Z
author_association: NONE
diff_hunk: @@ -172,13 +172,11 @@ type gateway struct {
 	rstPin  board.GPIOPin
 	pwrPin  board.GPIOPin
 
-	logReader *os.File
-	logWriter *os.File
-	dataFile  *os.File
-	dataMu    sync.Mutex
-
-	region     regions.Region
+	logReader  *os.File
+	logWriter  *os.File
+	db         *sql.DB // store device information/keys for use across restarts in a database
path: gateway/gateway.go
user: login: oliviamiller
body: im not sure why the dataFile being removed is being shown as a diff, it is already removed in main
created_at: 2025-04-03T18:59:40Z
author_association: COLLABORATOR
diff_hunk: @@ -172,13 +172,11 @@ type gateway struct {
 	rstPin  board.GPIOPin
 	pwrPin  board.GPIOPin
 
-	logReader *os.File
-	logWriter *os.File
-	dataFile  *os.File
-	dataMu    sync.Mutex
-
-	region     regions.Region
+	logReader  *os.File
+	logWriter  *os.File
+	db         *sql.DB // store device information/keys for use across restarts in a database
path: gateway/gateway.go
user: login: JohnN193
body: it is no longer shown as a diff on my end
created_at: 2025-04-03T19:03:30Z
author_association: COLLABORATOR
diff_hunk: @@ -172,13 +172,11 @@ type gateway struct {
 	rstPin  board.GPIOPin
 	pwrPin  board.GPIOPin
 
-	logReader *os.File
-	logWriter *os.File
-	dataFile  *os.File
-	dataMu    sync.Mutex
-
-	region     regions.Region
+	logReader  *os.File
+	logWriter  *os.File
+	db         *sql.DB // store device information/keys for use across restarts in a database
path: gateway/gateway.go
user: login: randhid
body: Ah - the culprit may infact be moving `region` under `regionInfo` and git is showing the lines around that as a diff. It sometimes does that - resolving.
created_at: 2025-04-03T19:16:02Z
author_association: NONE
diff_hunk: @@ -107,6 +108,8 @@ func newLHT65N(
 		return nil, err
 	}
 
+	n.node.Workers = utils.NewBackgroundStoppableWorkers(n.node.PollGateway)
path: draginolht65n/sensor.go
user: login: JohnN193
body: so it would be gross, but within `ReconfigureWithConfig` you could technically unlock the mutex to run `GetAndUpdateDeviceInfo(ctx)`, then lock again before starting the background thread. Not sure if theres another way to solve it with our setup
created_at: 2025-04-03T19:19:52Z
author_association: COLLABORATOR
diff_hunk: @@ -107,6 +108,8 @@ func newLHT65N(
 		return nil, err
 	}
 
+	n.node.Workers = utils.NewBackgroundStoppableWorkers(n.node.PollGateway)
path: draginolht65n/sensor.go
user: login: oliviamiller
body: Yeah i considered doing that but thought it was gross, but fine with changing it to that if we want to remove the repeated code
created_at: 2025-04-03T19:22:04Z
author_association: COLLABORATOR
diff_hunk: @@ -107,6 +108,8 @@ func newLHT65N(
 		return nil, err
 	}
 
+	n.node.Workers = utils.NewBackgroundStoppableWorkers(n.node.PollGateway)
path: draginolht65n/sensor.go
user: login: oliviamiller
body: Spoke with john offline: ended up putting the start of the thread in reconfigureWithConfig as we can start it without holding the lock, and keeping the GetAndUpdateDeviceInfo in the node's reconfigure function as it needs the lock.
created_at: 2025-04-03T19:46:41Z
author_association: COLLABORATOR

PR comments:
user: login: oliviamiller
created_at: 2025-04-02T20:42:04Z
body: Updated this PR to add the pollGateway function which will call a docommand on the gateway to return the device info from the DB, and then updates those fields in the node struct. This is how we are getting an updated MinInterval and can use it for sharing keys in the future. 
author_association: COLLABORATOR
user: login: oliviamiller
created_at: 2025-04-03T19:48:18Z
body: > Have we verified the following through manual testing:
> 
> 1. No hang on viam server after a reconfigure
> 2. No hang on viam server after a module restart.
> 3. Successful reconfiguration of any of our nodes.
> 4. Successful reconfiguration with data capture enabled - this shows that the code is functional even with a concurrent call to Readings as a reconfigure is called.
> 5. Successful restart with data capture enabled.

confirmed all these cases work as expected 
author_association: COLLABORATOR
user: login: randhid
created_at: 2025-04-03T20:00:05Z
body: > > Have we verified the following through manual testing:
> > 
> > 1. No hang on viam server after a reconfigure
> > 2. No hang on viam server after a module restart.
> > 3. Successful reconfiguration of any of our nodes.
> > 4. Successful reconfiguration with data capture enabled - this shows that the code is functional even with a concurrent call to Readings as a reconfigure is called.
> > 5. Successful restart with data capture enabled.
> 
> confirmed all these cases work as expected

Can you describe the result for each test? It is good to document things explicitly in general.
author_association: NONE
user: login: oliviamiller
created_at: 2025-04-03T20:15:05Z
body: 
> > > Have we verified the following through manual testing:
> > > 
> > > 1. No hang on viam server after a reconfigure
> > > 2. No hang on viam server after a module restart.
> > > 3. Successful reconfiguration of any of our nodes.
> > > 4. Successful reconfiguration with data capture enabled - this shows that the code is functional even with a concurrent call to Readings as a reconfigure is called.
> > > 5. Successful restart with data capture enabled.
> > 
> > 
> > confirmed all these cases work as expected
> 
> Can you describe the result for each test? It is good to document things explicitly in general.

1. Reconfiguring gateway restarts hardware and gateway continues parsing uplinks.
2. all module components shut down and restart successfully after restarting the module. 
3. reconfiguring the uplink_interval_min on a node component triggers a interval downlink to be added to the queue, and errors if its below the minimum interval (won't be able to see the error in logs until next rdk release due to [bug](https://viam.atlassian.net/browse/RSDK-10368))
4. tests 1 and 3 behave the same way when data capture is enabled on gateway or node component. 
5. viam-server and module is shutting down and restarting cleanly with data capture enabled. 
author_association: COLLABORATOR