Skip to content

Commit

Permalink
RSDK-9808 Persist OTAA data across restarts (#22)
Browse files Browse the repository at this point in the history
* save data

* add persitent data

* lint

* helpers

* clean up

* add test cases

* move code around

* lint

* add test

* fix bug

* fix

* pr

* fix bug

* add mutex

* remove debuging log
  • Loading branch information
oliviamiller authored Feb 3, 2025
1 parent b77b66a commit 8fd19a3
Show file tree
Hide file tree
Showing 6 changed files with 644 additions and 61 deletions.
114 changes: 102 additions & 12 deletions gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import "C"

import (
"bufio"
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"gateway/node"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -64,6 +67,14 @@ type Config struct {
BoardName string `json:"board"`
}

// deviceInfo is a struct containing OTAA device information.
// This info is saved across module restarts for each device.
type deviceInfo struct {
DevEUI string `json:"dev_eui"`
DevAddr string `json:"dev_addr"`
AppSKey string `json:"app_skey"`
}

func init() {
resource.RegisterComponent(
sensor.API,
Expand All @@ -89,7 +100,6 @@ func (conf *Config) Validate(path string) ([]string, error) {
deps = append(deps, conf.BoardName)

return deps, nil

}

// Gateway defines a lorawan gateway.
Expand All @@ -114,6 +124,7 @@ type gateway struct {

logReader *os.File
logWriter *os.File
dataFile *os.File
}

// NewGateway creates a new gateway
Expand All @@ -129,7 +140,17 @@ func NewGateway(
started: false,
}

err := g.Reconfigure(ctx, deps, conf)
// Create or open the file used to save device data across restarts.
moduleDataDir := os.Getenv("VIAM_MODULE_DATA")
filePath := filepath.Join(moduleDataDir, "devicedata.txt")
file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0o666)
if err != nil {
return nil, err
}

g.dataFile = file

err = g.Reconfigure(ctx, deps, conf)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -378,10 +399,13 @@ func (g *gateway) updateReadings(name string, newReadings map[string]interface{}

// DoCommand validates that the dependency is a gateway, and adds and removes nodes from the device maps.
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.
if _, ok := cmd["validate"]; ok {
return map[string]interface{}{"validate": 1}, nil
}

// Add the nodes to the list of devices.
if newNode, ok := cmd["register_device"]; ok {
if newN, ok := newNode.(map[string]interface{}); ok {
Expand All @@ -391,16 +415,31 @@ func (g *gateway) DoCommand(ctx context.Context, cmd map[string]interface{}) (ma
}

oldNode, exists := g.devices[node.NodeName]
if !exists {
switch exists {
case false:
g.devices[node.NodeName] = node
return map[string]interface{}{}, nil
case true:
// node with that name already exists, merge them
mergedNode, err := mergeNodes(node, oldNode)
if err != nil {
return nil, err
}
g.devices[node.NodeName] = mergedNode
}
// node with that name already exists, merge them
mergedNode, err := mergeNodes(node, oldNode)
// Check if the device is in the persistent data file, if it is add the OTAA info.
deviceInfo, err := g.searchForDeviceInFile(g.devices[node.NodeName].DevEui)
if err != nil {
return nil, err
if !errors.Is(err, errNoDevice) {
return nil, fmt.Errorf("error while searching for device in file: %w", err)
}
}
if deviceInfo != nil {
// device was found in the file, update the gateway's device map with the device info.
err = g.updateDeviceInfo(g.devices[node.NodeName], deviceInfo)
if err != nil {
return nil, fmt.Errorf("error while updating device info: %w", err)
}
}
g.devices[node.NodeName] = mergedNode
}
}
// Remove a node from the device map and readings map.
Expand All @@ -416,6 +455,49 @@ func (g *gateway) DoCommand(ctx context.Context, cmd map[string]interface{}) (ma
return map[string]interface{}{}, nil
}

// searchForDeviceInfoInFile searhces for device address match in the module's data file and returns the device info.
func (g *gateway) searchForDeviceInFile(devEUI []byte) (*deviceInfo, error) {
// read all the saved devices from the file
savedDevices, err := readFromFile(g.dataFile)
if err != nil {
return nil, fmt.Errorf("failed to read device info from file: %w", err)
}
// Check if the dev EUI is in the file.
for _, d := range savedDevices {
savedEUI, err := hex.DecodeString(d.DevEUI)
if err != nil {
return nil, fmt.Errorf("failed to decode file's dev EUI: %w", err)
}

if bytes.Equal(devEUI, savedEUI) {
// device found in the file
return &d, nil
}
}
return nil, errNoDevice
}

// updateDeviceInfo adds the device info from the file into the gateway's device map.
func (g *gateway) updateDeviceInfo(device *node.Node, d *deviceInfo) error {
// Update the fields in the map with the info from the file.
appsKey, err := hex.DecodeString(d.AppSKey)
if err != nil {
return fmt.Errorf("failed to decode file's app session key: %w", err)
}

savedAddr, err := hex.DecodeString(d.DevAddr)
if err != nil {
return fmt.Errorf("failed to decode file's dev addr: %w", err)
}

device.AppSKey = appsKey
device.Addr = savedAddr

// Update the device in the map.
g.devices[device.NodeName] = device
return nil
}

// mergeNodes merge the fields from the oldNode and the newNode sent from reconfigure.
func mergeNodes(newNode, oldNode *node.Node) (*node.Node, error) {
mergedNode := &node.Node{}
Expand Down Expand Up @@ -505,17 +587,25 @@ func (g *gateway) Close(ctx context.Context) error {

if g.loggingWorker != nil {
if g.logReader != nil {
//nolint:errcheck
g.logReader.Close()
if err := g.logReader.Close(); err != nil {
g.logger.Errorf("error closing log reader: %s", err)
}
}
if g.logWriter != nil {
//nolint:errcheck
g.logWriter.Close()
if err := g.logWriter.Close(); err != nil {
g.logger.Errorf("error closing log writer: %s", err)
}
}
g.loggingWorker.Stop()
delete(loggingRoutineStarted, g.Name().Name)
}

if g.dataFile != nil {
if err := g.dataFile.Close(); err != nil {
g.logger.Errorf("error closing data file: %s", err)
}
}

return nil
}

Expand Down
139 changes: 134 additions & 5 deletions gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package gateway

import (
"context"
"encoding/json"
"fmt"
"gateway/node"
"testing"

Expand All @@ -13,6 +15,47 @@ import (
"go.viam.com/utils/protoutils"
)

// setupTestGateway creates a test gateway with a configured test device.
func setupTestGateway(t *testing.T) *gateway {
//Create a temp device data file for testing
file := createDataFile(t)

testDevices := make(map[string]*node.Node)
testNode := &node.Node{
NodeName: testNodeName,
DecoderPath: testDecoderPath,
JoinType: "OTAA",
DevEui: testDevEUI,
}
testDevices[testNodeName] = testNode

return &gateway{
logger: logging.NewTestLogger(t),
devices: testDevices,
dataFile: file,
}
}

// creates a test gateway with device info populated in the file
func setupFileAndGateway(t *testing.T) *gateway {
g := setupTestGateway(t)

// Write device info to file
devices := []deviceInfo{
{
DevEUI: fmt.Sprintf("%X", testDevEUI),
DevAddr: fmt.Sprintf("%X", testDeviceAddr),
AppSKey: fmt.Sprintf("%X", testAppSKey),
},
}
data, err := json.MarshalIndent(devices, "", " ")
test.That(t, err, test.ShouldBeNil)
_, err = g.dataFile.Write(data)
test.That(t, err, test.ShouldBeNil)

return g
}

func TestValidate(t *testing.T) {
// Test valid config with default bus
resetPin := 25
Expand Down Expand Up @@ -63,10 +106,7 @@ func TestValidate(t *testing.T) {
}

func TestDoCommand(t *testing.T) {
g := &gateway{
devices: make(map[string]*node.Node),
}

g := setupFileAndGateway(t)
s := (sensor.Sensor)(g)

n := node.Node{
Expand Down Expand Up @@ -94,7 +134,9 @@ func TestDoCommand(t *testing.T) {
test.That(t, err, test.ShouldBeNil)
}

// Test Register device command
// Test Register device command - device not in file
// clear devices
g.devices = map[string]*node.Node{}
registerCmd := make(map[string]interface{})
registerCmd["register_device"] = n
doOverWire(s, registerCmd)
Expand All @@ -116,6 +158,24 @@ func TestDoCommand(t *testing.T) {
test.That(t, dev.DevEui, test.ShouldResemble, testDevEUI)
test.That(t, dev.DecoderPath, test.ShouldResemble, "/newpath")

// Test that if device is in file, OTAA fields get populated
// clear map and device otaa info for the new test
dev = g.devices[testNodeName]
g.devices = map[string]*node.Node{}
dev.AppSKey = nil
dev.Addr = nil
registerCmd["register_device"] = dev
doOverWire(s, registerCmd)
test.That(t, len(g.devices), test.ShouldEqual, 1)
dev, ok = g.devices[testNodeName]
test.That(t, ok, test.ShouldBeTrue)
test.That(t, dev.AppKey, test.ShouldResemble, testAppKey)
test.That(t, dev.DevEui, test.ShouldResemble, testDevEUI)
test.That(t, dev.DecoderPath, test.ShouldResemble, "/newpath")
// The dev addr and AppsKey should also be populated
test.That(t, dev.AppSKey, test.ShouldResemble, testAppSKey)
test.That(t, dev.Addr, test.ShouldResemble, testDeviceAddr)

// Test remove device command
removeCmd := make(map[string]interface{})
removeCmd["remove_device"] = n.NodeName
Expand Down Expand Up @@ -329,6 +389,70 @@ func TestStartCLogging(t *testing.T) {
test.That(t, loggingRoutineStarted["test-gateway"], test.ShouldBeTrue)
}

func TestSearchForDeviceInFile(t *testing.T) {
g := setupTestGateway(t)

// Device found in file should return device info
devices := []deviceInfo{
{
DevEUI: fmt.Sprintf("%X", testDevEUI),
DevAddr: fmt.Sprintf("%X", testDeviceAddr),
AppSKey: "5572404C694E6B4C6F526132303138323",
},
}
data, err := json.MarshalIndent(devices, "", " ")
test.That(t, err, test.ShouldBeNil)
_, err = g.dataFile.Write(data)
test.That(t, err, test.ShouldBeNil)

device, err := g.searchForDeviceInFile(testDevEUI)
test.That(t, err, test.ShouldBeNil)
test.That(t, device, test.ShouldNotBeNil)
test.That(t, device.DevAddr, test.ShouldEqual, fmt.Sprintf("%X", testDeviceAddr))

// Device not found in file should return errNoDevice
unknownAddr := []byte{0x01, 0x02, 0x03, 0x04}
device, err = g.searchForDeviceInFile(unknownAddr)
test.That(t, err, test.ShouldBeError, errNoDevice)
test.That(t, device, test.ShouldBeNil)

// Test File read error
g.dataFile.Close()
_, err = g.searchForDeviceInFile(testDeviceAddr)
test.That(t, err, test.ShouldNotBeNil)
test.That(t, err.Error(), test.ShouldContainSubstring, "failed to read device info from file")
}

func TestUpdateDeviceInfo(t *testing.T) {
g := setupTestGateway(t)

newAppSKey := []byte{
0x55, 0x72, 0x40, 0x4C,
0x69, 0x6E, 0x6B, 0x4C,
0x6F, 0x52, 0x61, 0x32,
0x31, 0x30, 0x32, 0x23,
}

newDevAddr := []byte{0xe2, 0x73, 0x65, 0x67}

// Test 1: Successful update
validInfo := &deviceInfo{
DevEUI: fmt.Sprintf("%X", testDevEUI), // matches dev EUI on the gateway map
DevAddr: fmt.Sprintf("%X", newDevAddr),
AppSKey: fmt.Sprintf("%X", newAppSKey),
}

device := g.devices[testNodeName]

err := g.updateDeviceInfo(device, validInfo)
test.That(t, err, test.ShouldBeNil)
test.That(t, device, test.ShouldNotBeNil)
test.That(t, device.NodeName, test.ShouldEqual, testNodeName)
test.That(t, device.AppSKey, test.ShouldResemble, newAppSKey)
test.That(t, device.Addr, test.ShouldResemble, newDevAddr)

}

func TestClose(t *testing.T) {
// Create a gateway instance for testing
cfg := resource.Config{
Expand Down Expand Up @@ -372,4 +496,9 @@ func TestClose(t *testing.T) {
_, err = g.logReader.Read(buf)
test.That(t, err, test.ShouldNotBeNil)
}
if g.dataFile != nil {
buf := make([]byte, 1)
_, err = g.dataFile.Read(buf)
test.That(t, err, test.ShouldNotBeNil)
}
}
Loading

0 comments on commit 8fd19a3

Please sign in to comment.