diff --git a/.gitignore b/.gitignore index 57d2a94..4fcbbe9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ # Build files /build/ /dist/ + +# macOS related +*.DS_store diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3fb59..ee009c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Support to convert Kerlink Wanesy CSV format to The Things Stack JSON. + ### Changed ### Deprecated diff --git a/README.md b/README.md index 6d1ad08..b58e9f7 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,47 @@ $ ttn-lw-migrate firefly application --all --verbose > devices.json $ ttn-lw-migrate firefly application --all --invalidate-keys > devices.json ``` +## Wanesy + +Migration from Kerlink's Wanesy requires exporting the device data into a [CSV](./pkg/source/wanesy/testdata/test.csv) file and feeding it into this tool. Please reach out to Kerlink to get an export of the devices that need to be migrated. + +### Configuration + +Configure with environment variables, or command-line arguments. + +See `ttn-lw-migrate wanesy {device|application} --help` for more details. + +The following example shows how to set options via environment variables. + +```bash +$ export APP_ID=my-test-app # Application ID for the exported devices +$ export FREQUENCY_PLAN_ID=EU_863_870 # Frequency Plan ID for the exported devices +$ export CSV_PATH= # Local path to the exported CSV file. +``` + +### Notes + +- The export process will halt if any error occurs. +- Since the migration tool uses a CSV file that's exported from WMC and does not interact with the API, make sure to remove/clean up the devices on WMC once the migration is completed. + +### Export Devices + +To export a single device using its Device EUI (e.g. `1111111111111112`): + +```bash +# Export a device from the CSV to TTS format. +$ ttn-lw-migrate wanesy device 1111111111111112 > devices.json +``` + +### Export All Devices + +In order to export all devices from the CSV file, use the `application` command. + +```bash +# Export all devices from the CSV. +$ ttn-lw-migrate wanesy application --all +``` + ## Development Environment Requires Go version 1.16 or higher. [Download Go](https://golang.org/dl/). diff --git a/cmd/root.go b/cmd/root.go index 05750a4..5465583 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "go.thethings.network/lorawan-stack-migrate/cmd/firefly" "go.thethings.network/lorawan-stack-migrate/cmd/ttnv2" "go.thethings.network/lorawan-stack-migrate/cmd/tts" + "go.thethings.network/lorawan-stack-migrate/cmd/wanesy" "go.thethings.network/lorawan-stack-migrate/pkg/export" "go.thethings.network/lorawan-stack-migrate/pkg/source" ) @@ -81,4 +82,5 @@ func init() { rootCmd.AddCommand(tts.TTSCmd) rootCmd.AddCommand(chirpstack.ChirpStackCmd) rootCmd.AddCommand(firefly.FireflyCmd) + rootCmd.AddCommand(wanesy.WanesyCmd) } diff --git a/cmd/wanesy/wanesy.go b/cmd/wanesy/wanesy.go new file mode 100644 index 0000000..66e15f1 --- /dev/null +++ b/cmd/wanesy/wanesy.go @@ -0,0 +1,27 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wanesy + +import ( + "go.thethings.network/lorawan-stack-migrate/pkg/commands" + _ "go.thethings.network/lorawan-stack-migrate/pkg/source/wanesy" +) + +const sourceName = "wanesy" + +// WanesyCmd represents the Wanesy source. +var WanesyCmd = commands.Source(sourceName, + "Migrate from Wanesy Management Center", +) diff --git a/go.mod b/go.mod index 6137dbb..d6154fc 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/brocaar/chirpstack-api/go/v3 v3.12.5 github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b github.com/mgechev/revive v1.3.6 + github.com/smarty/assertions v1.15.1 github.com/smartystreets/assertions v1.13.1 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 diff --git a/pkg/source/wanesy/config.go b/pkg/source/wanesy/config.go new file mode 100644 index 0000000..b649783 --- /dev/null +++ b/pkg/source/wanesy/config.go @@ -0,0 +1,137 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wanesy + +import ( + "encoding/csv" + "encoding/json" + "net/http" + "os" + "strings" + + "github.com/spf13/pflag" + + "go.thethings.network/lorawan-stack-migrate/pkg/source" + "go.thethings.network/lorawan-stack/v3/pkg/fetch" + "go.thethings.network/lorawan-stack/v3/pkg/frequencyplans" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" +) + +type Config struct { + src source.Config + + appID string + frequencyPlanID string + csvPath string + all bool + + derivedMacVersion ttnpb.MACVersion + derivedPhyVersion ttnpb.PHYVersion + + flags *pflag.FlagSet + fpStore *frequencyplans.Store +} + +// NewConfig returns a new Wanesy configuration. +func NewConfig() *Config { + config := &Config{ + flags: &pflag.FlagSet{}, + } + config.flags.StringVar(&config.frequencyPlanID, + "frequency-plan-id", + "", + "Frequency Plan ID for the exported devices") + config.flags.StringVar(&config.appID, + "app-id", + "", + "Application ID for the exported devices") + config.flags.StringVar(&config.csvPath, + "csv-path", + "", + "Path to the CSV file exported from Wanesy Management Center") + config.flags.BoolVar(&config.all, + "all", + false, + "Export all devices in the CSV. This is only used by the application command") + return config +} + +// Initialize the configuration. +func (c *Config) Initialize(src source.Config) error { + c.src = src + + if c.appID = os.Getenv("APP_ID"); c.appID == "" { + return errNoAppID.New() + } + if c.frequencyPlanID = os.Getenv("FREQUENCY_PLAN_ID"); c.frequencyPlanID == "" { + return errNoFrequencyPlanID.New() + } + if c.csvPath = os.Getenv("CSV_PATH"); c.csvPath == "" { + return errNoCSVFileProvided.New() + } + + fpFetcher, err := fetch.FromHTTP(http.DefaultClient, src.FrequencyPlansURL) + if err != nil { + return err + } + c.fpStore = frequencyplans.NewStore(fpFetcher) + + return nil +} + +// Flags returns the flags for the configuration. +func (c *Config) Flags() *pflag.FlagSet { + return c.flags +} + +// ImportDevices imports the devices from the provided CSV file. +func ImportDevices(csvPath string) (Devices, error) { + raw, err := os.ReadFile(csvPath) + if err != nil { + return nil, err + } + reader := csv.NewReader(strings.NewReader(string(raw))) + readValues, err := reader.ReadAll() + if err != nil { + return nil, err + } + if len(readValues) < 2 { + return nil, errNoValuesInCSV.New() + } + values := make([]map[string]string, 0) + for i := 1; i < len(readValues); i++ { + keys := readValues[0] + value := make(map[string]string) + for j := 0; j < len(keys); j++ { + noOfcolumns := len(readValues[i]) + if j >= noOfcolumns { + value[keys[j]] = "" // Fill empty columns. + continue + } + value[keys[j]] = readValues[i][j] + } + values = append(values, value) + } + j, err := json.Marshal(values) + if err != nil { + return nil, err + } + devices := make(Devices) + err = devices.UnmarshalJSON(j) + if err != nil { + return nil, err + } + return devices, nil +} diff --git a/pkg/source/wanesy/messages.go b/pkg/source/wanesy/messages.go new file mode 100644 index 0000000..623bd9d --- /dev/null +++ b/pkg/source/wanesy/messages.go @@ -0,0 +1,272 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wanesy + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/TheThingsNetwork/go-utils/random" + "go.thethings.network/lorawan-stack-migrate/pkg/util" + "go.thethings.network/lorawan-stack/v3/pkg/frequencyplans" + "go.thethings.network/lorawan-stack/v3/pkg/networkserver/mac" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/types" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Devices is a list of devices. +type Devices map[types.EUI64]Device + +// UnmarshalJSON implements json.Unmarshaler. +func (d Devices) UnmarshalJSON(b []byte) error { + var devs []Device + if err := json.Unmarshal(b, &devs); err != nil { + return err + } + for _, dev := range devs { + var devEUI types.EUI64 + if err := devEUI.UnmarshalText([]byte(dev.DevEui)); err != nil { + return err + } + if len(dev.DevAddr)%2 != 0 { + // Prepend 0 to make the length 8. + dev.DevAddr = fmt.Sprintf("0%s", dev.DevAddr) + } + d[devEUI] = dev + } + return nil +} + +// Device is a LoRaWAN end device exported from Wanesy Management Center. +type Device struct { + DevEui string `json:"devEui"` + ClusterID string `json:"clusterId"` + ClusterName string `json:"clusterName"` + Name string `json:"name"` + ClassType string `json:"classType"` + RfRegion string `json:"rfRegion"` + Country string `json:"country"` + MacVersion string `json:"macVersion"` + RegParamsRevision string `json:"regParamsRevision"` + Profile string `json:"profile"` + AdrEnabled string `json:"adrEnabled"` + Activation string `json:"activation"` + AppEui string `json:"appEui"` + AppKey string `json:"appKey"` + FCntDown string `json:"fcntDown"` + FCntUp string `json:"fcntUp"` + DevNonceCounter string `json:"devNonceCounter"` + FNwkSIntKey string `json:"fNwkSIntKey"` + SNwkSIntKey string `json:"sNwkSIntKey"` + Rx1Delay string `json:"rx1Delay"` + Rx1DrOffset string `json:"rx1DrOffset"` + Rx2Dr string `json:"rx2Dr"` + Rx2Freq string `json:"rx2Freq"` + RxWindows string `json:"rxWindows"` + CfList string `json:"cfList"` + DwellTime string `json:"dwellTime"` + PingSlotDr string `json:"pingSlotDr"` + PingSlotFreq string `json:"pingSlotFreq"` + Geolocation string `json:"geolocation"` + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` + Altitude string `json:"altitude"` + Status string `json:"status"` + LastDataUpMessage string `json:"lastDataUpMessage"` + LastDataDownMessage string `json:"lastDataDownMessage"` + LastDataUpDr string `json:"lastDataUpDr"` + DeviceProfile string `json:"device_profile"` + DevAddr string `json:"dev_addr"` + NwkSKey string `json:"NwkSKey"` + AppSKey string `json:"AppSKey"` +} + +// EndDevice converts a Wanesy device to a TTS device. +func (d Device) EndDevice(fpStore *frequencyplans.Store, applicationID, frequencyPlanID string) (*ttnpb.EndDevice, error) { + var devEUI types.EUI64 + if err := devEUI.UnmarshalText([]byte(d.DevEui)); err != nil { + return nil, err + } + ret := &ttnpb.EndDevice{ + Name: d.Name, + FrequencyPlanId: frequencyPlanID, + Ids: &ttnpb.EndDeviceIdentifiers{ + ApplicationIds: &ttnpb.ApplicationIdentifiers{ApplicationId: applicationID}, + DevEui: devEUI.Bytes(), + }, + MacSettings: &ttnpb.MACSettings{}, + SupportsClassC: d.ClassType == "C", + SupportsClassB: d.ClassType == "B", + SupportsJoin: d.Activation == "OTAA", + } + if ret.SupportsJoin { + var ( + joinEUI types.EUI64 + err error + ) + ret.RootKeys = &ttnpb.RootKeys{AppKey: &ttnpb.KeyEnvelope{}} + ret.RootKeys.AppKey.Key, err = util.UnmarshalTextToBytes(&types.AES128Key{}, d.AppKey) + if err != nil { + return nil, err + } + if err := joinEUI.UnmarshalText([]byte(d.AppEui)); err != nil { + return nil, err + } + ret.Ids.JoinEui = joinEUI.Bytes() + } + if d.Rx2Dr != "" { + s, err := strconv.ParseUint(d.Rx2Dr, 16, 32) + if err != nil { + return nil, err + } + ret.MacSettings.DesiredRx2DataRateIndex = &ttnpb.DataRateIndexValue{ + Value: ttnpb.DataRateIndex(int32(s)), + } + } + switch d.MacVersion { + case "1.0.0": + ret.LorawanVersion = ttnpb.MACVersion_MAC_V1_0 + ret.LorawanPhyVersion = ttnpb.PHYVersion_TS001_V1_0 + case "1.0.1": + ret.LorawanVersion = ttnpb.MACVersion_MAC_V1_0_1 + ret.LorawanPhyVersion = ttnpb.PHYVersion_TS001_V1_0_1 + case "1.0.2": + ret.LorawanVersion = ttnpb.MACVersion_MAC_V1_0_2 + switch d.RegParamsRevision { + case "A": + ret.LorawanPhyVersion = ttnpb.PHYVersion_RP001_V1_0_2 + case "B": + ret.LorawanPhyVersion = ttnpb.PHYVersion_RP001_V1_0_2_REV_B + default: + return nil, errInvalidPHYForMACVersion.WithAttributes( + "phy_version", + d.RegParamsRevision, + "mac_version", + d.MacVersion, + ) + } + case "1.0.3": + ret.LorawanVersion = ttnpb.MACVersion_MAC_V1_0_3 + ret.LorawanPhyVersion = ttnpb.PHYVersion_RP001_V1_0_3_REV_A + case "1.0.4": + ret.LorawanVersion = ttnpb.MACVersion_MAC_V1_0_4 + ret.LorawanPhyVersion = ttnpb.PHYVersion_RP002_V1_0_4 + case "1.1.0": + ret.LorawanVersion = ttnpb.MACVersion_MAC_V1_1 + switch d.RegParamsRevision { + case "A": + ret.LorawanPhyVersion = ttnpb.PHYVersion_RP001_V1_1_REV_A + case "B": + ret.LorawanPhyVersion = ttnpb.PHYVersion_RP001_V1_1_REV_B + default: + return nil, errInvalidPHYForMACVersion.WithAttributes( + "phy_version", + d.RegParamsRevision, + "mac_version", + d.MacVersion, + ) + } + default: + return nil, errInvalidMACVersion.WithAttributes("mac_version", d.MacVersion) + } + + if d.Longitude != "" && d.Latitude != "" && d.Altitude != "" { + latitude, _ := strconv.ParseFloat(d.Latitude, 64) + longitude, _ := strconv.ParseFloat(d.Longitude, 64) + altitude, err := strconv.ParseUint(d.Rx2Dr, 16, 32) + if err != nil { + return nil, err + } + ret.Locations = map[string]*ttnpb.Location{ + "user": { + Latitude: latitude, + Longitude: longitude, + Altitude: int32(altitude), + Source: ttnpb.LocationSource_SOURCE_REGISTRY, + }, + } + } + + // Copy session information if available. + hasSession := d.DevAddr != "" && d.NwkSKey != "" && d.AppKey != "" + if hasSession || !ret.SupportsJoin { + var err error + ret.Session = &ttnpb.Session{Keys: &ttnpb.SessionKeys{AppSKey: &ttnpb.KeyEnvelope{}, FNwkSIntKey: &ttnpb.KeyEnvelope{}}} + ret.Session.DevAddr, err = util.UnmarshalTextToBytes(&types.DevAddr{}, d.DevAddr) + if err != nil { + return nil, err + } + if ret.SupportsJoin { + ret.Session.StartedAt = timestamppb.Now() + ret.Session.Keys.SessionKeyId = random.Bytes(16) + } + ret.Session.Keys.AppSKey.Key, err = util.UnmarshalTextToBytes(&types.AES128Key{}, d.AppSKey) + if err != nil { + return nil, err + } + ret.Session.Keys.FNwkSIntKey.Key, err = util.UnmarshalTextToBytes(&types.AES128Key{}, d.NwkSKey) + if err != nil { + return nil, err + } + switch ret.LorawanVersion { + case ttnpb.MACVersion_MAC_V1_1: + ret.Session.Keys.FNwkSIntKey.Key, err = util.UnmarshalTextToBytes(&types.AES128Key{}, d.FNwkSIntKey) + if err != nil { + return nil, err + } + ret.Session.Keys.NwkSEncKey = &ttnpb.KeyEnvelope{} + ret.Session.Keys.NwkSEncKey.Key, err = util.UnmarshalTextToBytes(&types.AES128Key{}, d.NwkSKey) + if err != nil { + return nil, err + } + ret.Session.Keys.SNwkSIntKey = &ttnpb.KeyEnvelope{} + ret.Session.Keys.SNwkSIntKey.Key, err = util.UnmarshalTextToBytes(&types.AES128Key{}, d.SNwkSIntKey) + if err != nil { + return nil, err + } + } + + // Set FrameCounters + s, err := strconv.ParseUint(d.FCntUp, 10, 32) + if err != nil { + return nil, err + } + ret.Session.LastFCntUp = uint32(s) + s, err = strconv.ParseUint(d.FCntDown, 10, 32) + if err != nil { + return nil, err + } + ret.Session.LastAFCntDown = uint32(s) + ret.Session.LastNFCntDown = uint32(s) + + // Create a MACState. + if ret.MacState, err = mac.NewState(ret, fpStore, ret.MacSettings); err != nil { + return nil, err + } + ret.MacState.CurrentParameters = ret.MacState.DesiredParameters + ret.MacState.CurrentParameters.Rx1Delay = ttnpb.RxDelay_RX_DELAY_1 // Fallback + if d.Rx1Delay != "" { + s, err = strconv.ParseUint(d.Rx1Delay, 10, 32) + if err != nil { + return nil, err + } + ret.MacState.CurrentParameters.Rx1Delay = ttnpb.RxDelay(s) + } + } + + return ret, nil +} diff --git a/pkg/source/wanesy/messages_test.go b/pkg/source/wanesy/messages_test.go new file mode 100644 index 0000000..55fd114 --- /dev/null +++ b/pkg/source/wanesy/messages_test.go @@ -0,0 +1,71 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wanesy_test + +import ( + "net/http" + "testing" + + "github.com/smarty/assertions" + "github.com/smarty/assertions/should" + . "go.thethings.network/lorawan-stack-migrate/pkg/source/wanesy" + "go.thethings.network/lorawan-stack/v3/pkg/fetch" + "go.thethings.network/lorawan-stack/v3/pkg/frequencyplans" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/types" +) + +func TestImportAndExport(t *testing.T) { + a := assertions.New(t) + devices, err := ImportDevices("./testdata/test.csv") + a.So(err, should.BeNil) + a.So(len(devices), should.Equal, 1) + var devEUI types.EUI64 + if err = devEUI.UnmarshalText([]byte("1111111111111111")); err != nil { + t.Fatalf("Failed to unmarshal EUI: %v", err) + } + dev, ok := devices[devEUI] + if !ok { + t.FailNow() + } + a.So(dev.AppEui, should.Equal, "1111111111111111") + a.So(dev.FCntUp, should.Equal, "20") + a.So(dev.FCntDown, should.Equal, "10") + a.So(dev.DevAddr, should.Equal, "01234567") + + fpFetcher, err := fetch.FromHTTP( + http.DefaultClient, + "https://raw.githubusercontent.com/TheThingsNetwork/lorawan-frequency-plans/master", + ) + if err != nil { + t.Fatalf("Failed to create fetcher: %v", err) + } + + v3Device, err := dev.EndDevice(frequencyplans.NewStore(fpFetcher), "test-app", "EU_863_870") + if err != nil { + t.Fatalf("Failed to convert device: %v", err) + } + a.So(v3Device, should.NotBeNil) + + // Check converted fields. + a.So(v3Device.Ids.DevEui, should.Resemble, devEUI.Bytes()) + a.So(v3Device.Session, should.NotBeNil) + a.So(v3Device.Session.DevAddr, should.Resemble, []byte{0x01, 0x23, 0x45, 0x67}) + a.So(v3Device.Session.LastFCntUp, should.Equal, 20) + a.So(v3Device.Session.LastAFCntDown, should.Equal, 10) + a.So(v3Device.LorawanPhyVersion, should.Equal, ttnpb.PHYVersion_PHY_V1_0_2_REV_A) + a.So(v3Device.LorawanVersion, should.Equal, ttnpb.MACVersion_MAC_V1_0_2) + a.So(v3Device.Session.Keys, should.NotBeNil) +} diff --git a/pkg/source/wanesy/source.go b/pkg/source/wanesy/source.go new file mode 100644 index 0000000..c2ba4e4 --- /dev/null +++ b/pkg/source/wanesy/source.go @@ -0,0 +1,95 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wanesy + +import ( + "context" + "os" + + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/types" + + "go.thethings.network/lorawan-stack-migrate/pkg/iterator" + "go.thethings.network/lorawan-stack-migrate/pkg/source" + "go.thethings.network/lorawan-stack-migrate/pkg/source/firefly/client" +) + +type Source struct { + *Config + *client.Client + + imported Devices +} + +func createNewSource(cfg *Config) source.CreateSource { + return func(ctx context.Context, src source.Config) (source.Source, error) { + if err := cfg.Initialize(src); err != nil { + return nil, err + } + devs, err := ImportDevices(cfg.csvPath) + if err != nil { + return nil, err + } + return Source{ + Config: cfg, + imported: devs, + }, nil + } +} + +// Iterator implements source.Source. +func (s Source) Iterator(isApplication bool) iterator.Iterator { + if !isApplication { + return iterator.NewReaderIterator(os.Stdin, '\n') + } + if s.all { + // WMC does not group devices by application. + // The `all` flag is used to export all the devices in the CSV. + return iterator.NewListIterator( + []string{"all"}, + ) + } + return iterator.NewNoopIterator() +} + +// ExportDevice implements the source.Source interface. +func (s Source) ExportDevice(devEUIString string) (*ttnpb.EndDevice, error) { + var devEUI types.EUI64 + if err := devEUI.UnmarshalText([]byte(devEUIString)); err != nil { + return nil, err + } + wmcdev, ok := s.imported[devEUI] + if !ok { + return nil, errNoDeviceFound.WithAttributes("eui", devEUIString) + } + v3dev, err := wmcdev.EndDevice(s.fpStore, s.appID, s.frequencyPlanID) + if err != nil { + return nil, err + } + return v3dev, nil +} + +// RangeDevices implements the source.Source interface. +func (s Source) RangeDevices(_ string, f func(source.Source, string) error) error { + for eui := range s.imported { + if err := f(s, eui.String()); err != nil { + return err + } + } + return nil +} + +// Close implements the Source interface. +func (s Source) Close() error { return nil } diff --git a/pkg/source/wanesy/testdata/test.csv b/pkg/source/wanesy/testdata/test.csv new file mode 100644 index 0000000..212121f --- /dev/null +++ b/pkg/source/wanesy/testdata/test.csv @@ -0,0 +1,2 @@ +devEui,clusterId,clusterName,name,classType,rfRegion,country,macVersion,regParamsRevision,profile,adrEnabled,activation,appEui,appKey,fcntDown,fcntUp,devNonceCounter,fNwkSIntKey,sNwkSIntKey,rx1Delay,rx1DrOffset,rx2Dr,rx2Freq,rxWindows,cfList,dwellTime,pingSlotDr,pingSlotFreq,geolocation,latitude,longitude,altitude,status,lastDataUpMessage,lastDataDownMessage,lastDataUpDr,device_profile,dev_addr,NwkSKey,AppSKey +1111111111111111,0001,Test,Test,A,EU868,,1.0.2,A,STATIC,True,OTAA,1111111111111111,22222222222222222222222222222222,10,20,False,,,,,,,,,,,,,,,,,,,SF7BW125,,1234567,33333333333333333333333333333333,44444444444444444444444444444444 diff --git a/pkg/source/wanesy/wanesy.go b/pkg/source/wanesy/wanesy.go new file mode 100644 index 0000000..d5976f9 --- /dev/null +++ b/pkg/source/wanesy/wanesy.go @@ -0,0 +1,43 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package wanesy provides functions to parse the WMC csv file and create a TTS device JSON. +package wanesy + +import ( + "go.thethings.network/lorawan-stack-migrate/pkg/source" + "go.thethings.network/lorawan-stack/v3/pkg/errors" +) + +var ( + errNoValuesInCSV = errors.DefineInvalidArgument("no_values_in_csv", "no values in CSV file") + errNoAppID = errors.DefineInvalidArgument("no_app_id", "no app id") + errNoCSVFileProvided = errors.DefineInvalidArgument("no_csv_file_provided", "no csv file provided") + errNoJoinEUI = errors.DefineInvalidArgument("no_join_eui", "no join eui") + errNoDeviceFound = errors.DefineInvalidArgument("no_device_found", "no device with eui `{eui}` found") + errNoFrequencyPlanID = errors.DefineInvalidArgument("no_frequency_plan_id", "no frequency plan ID") + errInvalidMACVersion = errors.DefineInvalidArgument("invalid_mac_version", "invalid MAC version `{mac_version}`") + errInvalidPHYForMACVersion = errors.DefineInvalidArgument("invalid_phy_for_mac_version", "invalid PHY version `{phy_version}` for MAC version `{mac_version}`") +) + +func init() { + cfg := NewConfig() + + source.RegisterSource(source.Registration{ + Name: "wanesy", + Description: "Migrate from Wanesy Management Center", + FlagSet: cfg.Flags(), + Create: createNewSource(cfg), + }) +}