So, I crudely hacked in the column which original script was missing (i.e., SkipFCntValidation), because I’m too lazy to manually update most of my devices (all my RadioBridge sensors need this, but Laird sensors don’t).
// Author: Orne Brocaar. Author of LoRa Server https://www.chirpstack.io
// Script taken from https://forum.chirpstack.io/t/creating-a-terraform-provider-for-loraserver/4081/2
//
// ignore WARNING: proto: file “common.proto” is already registered
// A future release will panic on registration conflicts. See:
// https://developers.google.com/protocol-buffers/docs/reference/go/faq#namespace-conflict
// fmg: added column "SkipFCntValidation"; now requires XLSX to have 8 columns; see DeviceImportRecord for type
// col0 col1 col2 col3 col4 col5 col6 col7
// devEUI applicationID deviceProfileID name description networkKey applicationKey skipFCntValidation
package main
import (
"context"
"crypto/tls"
"flag"
"log"
"github.com/pkg/errors"
"github.com/tealeg/xlsx"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"github.com/brocaar/lora-app-server/api"
)
// JWTCredentials provides JWT credentials for gRPC
type JWTCredentials struct {
token string
}
// GetRequestMetadata returns the meta-data for a request.
func (j *JWTCredentials) GetRequestMetadata(ctx context.Context, url ...string) (map[string]string, error) {
return map[string]string{
"authorization": j.token,
}, nil
}
// RequireTransportSecurity ...
func (j *JWTCredentials) RequireTransportSecurity() bool {
return false
}
// SetToken sets the JWT token.
func (j *JWTCredentials) SetToken(token string) {
j.token = token
}
// DeviceImportRecord defines a record for a device to import.
type DeviceImportRecord struct {
DevEUI string
ApplicationID int64
DeviceProfileID string
Name string
Description string
NetworkKey string
ApplicationKey string
SkipFCntValidation bool
}
var (
username string
password string
file string
apiHost string
apiInsecure bool
jwtCredentials *JWTCredentials
)
func init() {
jwtCredentials = &JWTCredentials{}
//fmg: flag.StringVar(&username, "username", "admin", "LoRa App Server username")
flag.StringVar(&username, "username", "admin", "LoRa App Server username")
//fmg: flag.StringVar(&password, "password", "admin", "LoRa App Server password")
flag.StringVar(&password, "password", "admin", "LoRa App Server password")
//fmg: flag.StringVar(&file, "file", "", "Path to Excel file")
flag.StringVar(&file, "file", "/home/ds-admin/Documents/2fmg_new_devices.xlsx", "Path to Excel file")
//fmg: flag.StringVar(&apiHost, "api", "localhost:8080", "hostname:port to LoRa App Server API")
flag.StringVar(&apiHost, "api", "10.1.6.90:8080", "hostname:port to LoRa App Server API")
//fmg: flag.BoolVar(&apiInsecure, "api-insecure", false, "LoRa App Server API does not use TLS")
flag.BoolVar(&apiInsecure, "api-insecure", true, "LoRa App Server API does not use TLS")
flag.Parse()
}
func getGRPCConn() (*grpc.ClientConn, error) {
dialOpts := []grpc.DialOption{
grpc.WithBlock(),
grpc.WithPerRPCCredentials(jwtCredentials),
}
if apiInsecure {
log.Println("using insecure api")
dialOpts = append(dialOpts, grpc.WithInsecure())
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
InsecureSkipVerify: true,
})))
}
conn, err := grpc.Dial(apiHost, dialOpts...)
if err != nil {
return nil, errors.Wrap(err, "grpc dial error")
}
return conn, nil
}
func login(conn *grpc.ClientConn) error {
internalClient := api.NewInternalServiceClient(conn)
resp, err := internalClient.Login(context.Background(), &api.LoginRequest{
Username: username,
Password: password,
})
if err != nil {
return errors.Wrap(err, "login error")
}
jwtCredentials.SetToken(resp.Jwt)
return nil
}
func getDeviceImportList() ([]DeviceImportRecord, error) {
xlFile, err := xlsx.OpenFile(file)
if err != nil {
return nil, errors.Wrap(err, "open excel file error")
}
var out []DeviceImportRecord
for _, sheet := range xlFile.Sheets {
for i, row := range sheet.Rows {
if i == 0 {
continue
}
if len(row.Cells) != 8 {
log.Fatalf("expected exactly 8 columns (row %d) but found %d", i+1, len(row.Cells))
}
devEUI := row.Cells[0].String()
applicationID, err := row.Cells[1].Int64()
if err != nil {
log.Fatalf("application id parse error (row %d): %s", i+1, err)
}
deviceProfileID := row.Cells[2].String()
name := row.Cells[3].String()
description := row.Cells[4].String()
networkKey := row.Cells[5].String()
applicationKey := row.Cells[6].String()
skipFCntValidation := row.Cells[7].Bool()
if err != nil {
log.Fatalf("SkipFCntValidation parse error (row %d): %s", i+1, err)
}
out = append(out, DeviceImportRecord{
DevEUI: devEUI,
ApplicationID: applicationID,
DeviceProfileID: deviceProfileID,
Name: name,
Description: description,
NetworkKey: networkKey,
ApplicationKey: applicationKey,
SkipFCntValidation: skipFCntValidation,
})
}
}
return out, nil
}
func importDevices(conn *grpc.ClientConn, devices []DeviceImportRecord) error {
deviceClient := api.NewDeviceServiceClient(conn)
for i, dev := range devices {
d := api.Device{
DevEui: dev.DevEUI,
Name: dev.Name,
ApplicationId: dev.ApplicationID,
Description: dev.Description,
DeviceProfileId: dev.DeviceProfileID,
//In http://10.1.6.90:8080/static/js/main.ee69ccb2.chunk.js found that variable for "Disable frame-counter validation" is "skipFCntCheck"
// {label:"Disable frame-counter validation",control:i.a.createElement(Ht.a,{id:"skipFCntCheck",checked:
// !!this.state.object.skipFCntCheck,onChange:this.onChange,color:"primary"})})),
// i.a.createElement(Kt.a,null,"Note that disabling the frame-counter validation will compromise security as it enables people to perform replay-attacks.")),
SkipFCntCheck: dev.SkipFCntValidation,
//Using search term "+site:github.com/brocaar skipFCntCheck", found skipFCntCheck being handled in:
//https://github.com/brocaar/chirpstack-network-server/blob/master/internal/storage/device_session.go
//Used in func deviceSessionToPB() as SkipFCntCheck: d.SkipFCntValidation,
//Used in func deviceSessionFromPB() as SkipFCntValidation: d.SkipFCntCheck,
//SkipFCntValidation: dev.SkipFCntValidation, //didn't work
//skip_f_cnt_check: dev.SkipFCntValidation, //didn't work
}
dk := api.DeviceKeys{
DevEui: dev.DevEUI,
NwkKey: dev.NetworkKey,
AppKey: dev.ApplicationKey,
}
_, err := deviceClient.Create(context.Background(), &api.CreateDeviceRequest{
Device: &d,
})
if err != nil {
if grpc.Code(err) == codes.AlreadyExists {
log.Printf("device %s already exists (row %d)", d.DevEui, i+2)
continue
}
log.Fatalf("import error (device %s row %d): %s", d.DevEui, i+2, err)
}
_, err = deviceClient.CreateKeys(context.Background(), &api.CreateDeviceKeysRequest{
DeviceKeys: &dk,
})
if err != nil {
if grpc.Code(err) == codes.AlreadyExists {
log.Printf("device-keys for device %s already exists (row %d)", d.DevEui, i+2)
continue
}
log.Fatalf("import error (device %s) (row %d): %s", d.DevEui, i+2, err)
}
}
return nil
}
func main() {
conn, err := getGRPCConn()
if err != nil {
log.Fatal("error connecting to api", err)
}
if err := login(conn); err != nil {
log.Fatal("login error", err)
}
rows, err := getDeviceImportList()
if err != nil {
log.Fatal("get device import records error", err)
}
if err := importDevices(conn, rows); err != nil {
log.Fatal("import error", err)
}
}
XLSX file needs 8th column: