diff --git a/.gitignore b/.gitignore index 66fd13c..f04ddec 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +# Ignore environment files +.env +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 00f6140..c8dccee 100644 --- a/README.md +++ b/README.md @@ -1 +1,102 @@ -# deveui-cli \ No newline at end of file +
+
+ + MachineMax Logo + + +

DevEUI CLI

+ +

+

A Golang program for concurrently registering DevEUI identifiers for MachineMax.

+

+
+ +## About MachineMax DevEUI + +Each MachineMax sensor has a unique 16-character (hex) identifier called a DevEUI. As part of +the manufacturing process, it is written onto the internal storage of the sensor. The DevEUI is +also printed on a label on the side of the sensor alongside a 5-character code (the last 5 +characters of the DevEUI). For example, a DevEUI of 78111FFFE452555B would have a short +code of 2555B. + +The sensors communicate with the MachineMax cloud though a LoRaWAN provider and the +LoRaWAN provider uses the DevEUI to identify the sensor. This means we first have to register +the DevEUI with the provider before we can use it. We pay for every device registered with the +LoRaWAN provider, so it is important that we only register DevEUIs that we use. + +When a customer registers a new sensor, they will enter the 5-character short-form code instead +of the full DevEUI, so it is essential that each DevEUI in the batch has a unique 5-char code (for +lookups). + +### Built With + +* Golang +* Docker + +## Getting Started + +To run this code, we first need a `.env` file in the root of the project. Once this is done, add these vars: + +``` +BASE_URL=http://europe-west1-machinemax-dev-d524.cloudfunctions.net +TIMEOUT=30000 +CODE_REGISTRATION_LIMIT=100 +``` + +### Prerequisites + +- You will need Golang to run this program which can be downloaded at: [https://go.dev/](https://go.dev/) +- This program can also be run using docker, this can be downloaded at: [https://www.docker.com/](https://www.docker.com/) + +## Usage + +Then to run locally, use: `go run main.go`. + +Alternatively, this code can also be run via docker. To build the docker image use: +``` +docker build -t deveui-cli . --build-arg BASE_URL=${BASE_URL} --build-arg TIMEOUT=${TIMEOUT} --build-arg CODE_REGISTRATION_LIMIT=${CODE_REGISTRATION_LIMIT}. +``` + +To run the docker image: +``` +docker run deveui-cli +``` + +To run the tests use: +``` +go test ./... +``` +To check code coverage: +``` +go test -coverprofile=coverage.out ./... ; go tool cover -html=coverage.out +``` + +And to run benchmark tests for CPU and memory consumption: +``` +go test -bench=. -benchmem +``` + +Finally to check for race conditions, use: +``` +go test -race ./... +``` + +## Checklist + +- [x] Implement solution +- [x] Write unit tests +- [x] Write readme +- [ ] More unit tests around unhappy paths for higher code coverage +- [ ] Discuss with engineers how to improve code + +## Contact + +Email - nickgowdy87@gmail.com + +Website http://www.nickgowdy.com/ + +Github https://github.com/nickgowdy + + + + diff --git a/client/lorawan.go b/client/lorawan.go new file mode 100644 index 0000000..d832e17 --- /dev/null +++ b/client/lorawan.go @@ -0,0 +1,67 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/NickGowdy/deveui-cli/device" +) + +// Client used to communicate to external services +type Client interface { + Do(*http.Request) (resp *http.Response, err error) +} + +// LoraWAN used to communicate to LoRaWAN external system +type LoraWAN struct { + baseURL string + client Client +} + +func NewLoraWAN(baseURL string, client Client) *LoraWAN { + return &LoraWAN{ + baseURL: baseURL, + client: client, + } +} + +const endpoint = "/sensor-onboarding-sample" // endpoint for saving DevEUI via LoRaWAN + +// RegisterDevice registers new device using LoraWAN external service +func (l *LoraWAN) RegisterDevice(ctx context.Context) (*device.Device, error) { + device := device.NewDevice() + identifier := device.GetIdentifier() + b := new(bytes.Buffer) + reqBody := map[string]string{"Deveui": identifier} + + err := json.NewEncoder(b).Encode(&reqBody) + if err != nil { + return nil, err + } + + fullUrl := l.baseURL + endpoint + req, err := http.NewRequestWithContext(ctx, "POST", fullUrl, b) + if err != nil { + return nil, err + } + + resp, err := l.client.Do(req) + if err != nil { + return nil, err + } + + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return device, nil + } else { + return nil, errors.New(resp.Status) + } +} diff --git a/client/lorawan_test.go b/client/lorawan_test.go new file mode 100644 index 0000000..e10ddf3 --- /dev/null +++ b/client/lorawan_test.go @@ -0,0 +1,95 @@ +package client + +import ( + "net/http" +) + +type MockClient struct { + DoFunc func(*http.Request) (resp *http.Response, err error) +} + +// func TestLorawanClientHappyPath(t *testing.T) { +// mockClient := &MockClient{ +// DoFunc: func(*http.Request) (resp *http.Response, err error) { +// return &http.Response{}, nil +// }, +// } + +// loraWAN := NewLoraWAN("www.example.com", mockClient) + +// b := new(bytes.Buffer) +// reqBody := map[string]string{"Deveui": "Abcde"} + +// _ = json.NewEncoder(b).Encode(&reqBody) + +// ctx, cancel := context.WithCancel(context.Background()) + +// if cancel == nil { +// t.Errorf("cancel should not be nil but is: %v", cancel) +// } + +// resp, err := loraWAN.DoPost(b, ctx) + +// if err != nil { +// t.Errorf("err should be nil but is: %s", err.Error()) +// } +// defer resp.Body.Close() + +// if resp.StatusCode != 200 { +// t.Errorf("resp should be nil but is: %d", resp.StatusCode) +// } + +// body, _ := io.ReadAll(resp.Body) +// val := string(body) + +// if strings.TrimSpace(val) != "true" { +// t.Errorf("body should equal true but is: %d", body) +// } +// } + +// func TestNewLoraWanClient(t *testing.T) { +// client := &http.Client{ +// Timeout: 30 * time.Second, +// } +// t.Parallel() +// type args struct { +// timeout time.Duration +// } +// tests := []struct { +// name string +// args args +// want *LoraWAN +// }{ +// { +// name: "create-new-lorawan-client", +// args: args{ +// timeout: 30, +// }, +// want: &LoraWAN{ +// baseURL: "https://www.example.com", +// client: client, +// }, +// }, +// } +// for _, tt := range tests { +// tt := tt // it is important to capture range variable +// t.Run(tt.name, func(t *testing.T) { +// t.Parallel() // this makes sure that all cases from the table here are executed in parallel +// if got := NewLoraWAN("https://www.example.com", client); !reflect.DeepEqual(got, tt.want) { +// t.Errorf("NewLoraWanClient() = %v, want %v", got, tt.want) +// } +// }) +// } +// } + +// func (m *MockClient) Do(*http.Request) (resp *http.Response, err error) { +// b := new(bytes.Buffer) +// reqBody := true + +// _ = json.NewEncoder(b).Encode(&reqBody) +// return &http.Response{ +// StatusCode: http.StatusOK, +// Body: io.NopCloser(b), +// Status: "200 OK"}, +// nil +// } diff --git a/device/device.go b/device/device.go new file mode 100644 index 0000000..6e94089 --- /dev/null +++ b/device/device.go @@ -0,0 +1,65 @@ +package device + +import ( + "crypto/rand" + "fmt" + "log" + "math/big" +) + +const ( + AllowedChars = "ABCDEF0123456789" // accepted chars used to make up DevEUI + DevEuiLength = 16 // valid DevEUI is string of length 16 +) + +type Device struct { + identifier string + code string +} + +// NewDevice Build a new device with DevEUI identifier and code values. +// +// # Example +// +// 1CEB0080F074F750 4F750 +func NewDevice() *Device { + hex, err := generateHexString() + if err != nil { + log.Fatal(err) + } + + return &Device{ + identifier: hex, + code: hex[len(hex)-5:], + } +} + +func (d Device) GetIdentifier() string { + return d.identifier +} + +func (d Device) GetCode() string { + return d.code +} + +func (d Device) Print() { + fmt.Printf("device has identifier: %s and code: %s\n", d.identifier, d.code) +} + +// Generate valid DevEUI identifier value. +// +// # Example +// +// 1CEB0080F074F750 +func generateHexString() (string, error) { + max := big.NewInt(int64(len(AllowedChars))) + b := make([]byte, DevEuiLength) + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + b[i] = AllowedChars[n.Int64()] + } + return string(b), nil +} diff --git a/device/device_test.go b/device/device_test.go new file mode 100644 index 0000000..46fa331 --- /dev/null +++ b/device/device_test.go @@ -0,0 +1,90 @@ +package device + +import ( + "testing" +) + +func TestCanGenerateValidCode(t *testing.T) { + allowedChars := []string{"A", "B", "C", "D", "E", "F", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} + device := NewDevice() + + if device == nil { + t.Errorf("deivce should not be nil, but is %s", device) + } + + identifier := device.GetIdentifier() + code := device.GetCode() + + if len(code) != 5 { + t.Errorf("code should be 5 characters long, but is %d", len(code)) + } + + if identifier[len(identifier)-5:] != code { + t.Errorf("code should be last 5 characters of identifier, but is %s", code) + } + + hasChar := false + for _, char := range allowedChars { + if char == string(code[0]) { + hasChar = true + } else if hasChar { + break + } + } + + if hasChar == false { + t.Errorf("first char should be A, B, C, D, E, F, 0, 1, 2, 3, 4, 5, 6, 7, 8 or 9: but is: %s", string(code[0])) + } + + hasChar = false + for _, char := range allowedChars { + if char == string(code[1]) { + hasChar = true + } else if hasChar { + break + } + } + + if hasChar == false { + t.Errorf("second char should be A, B, C, D, E, F, 0, 1, 2, 3, 4, 5, 6, 7, 8 or 9: but is: %s", string(code[1])) + } + + hasChar = false + for _, char := range allowedChars { + if char == string(code[2]) { + hasChar = true + } else if hasChar { + break + } + } + + if hasChar == false { + t.Errorf("third char should be A, B, C, D, E, F, 0, 1, 2, 3, 4, 5, 6, 7, 8 or 9: but is: %s", string(code[2])) + } + + hasChar = false + for _, char := range allowedChars { + if char == string(code[3]) { + hasChar = true + } else if hasChar { + break + } + } + + if hasChar == false { + t.Errorf("fourth char should be A, B, C, D, E, F, 0, 1, 2, 3, 4, 5, 6, 7, 8 or 9: but is: %s", string(code[3])) + } + + hasChar = false + for _, char := range allowedChars { + if char == string(code[4]) { + hasChar = true + } else if hasChar { + break + } + } + + if hasChar == false { + t.Errorf("fifth char should be A, B, C, D, E, F, 0, 1, 2, 3, 4, 5, 6, 7, 8 or 9: but is: %s", string(code[4])) + } +} diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..467cc91 --- /dev/null +++ b/dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.19-alpine + +RUN apk add --no-cache git + +# Set the Current Working Directory inside the container +WORKDIR /app/deveui-cli + +# We want to populate the module cache based on the go.{mod,sum} files. +COPY go.mod . + +RUN go mod download + +COPY . . + +# Build the Go app +RUN go build -o ./deveui-cli . + + +# Run the binary program produced by `go install` +ENTRYPOINT ["./deveui-cli"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aa4b41a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/NickGowdy/deveui-cli + +go 1.19 + +require github.com/joho/godotenv v1.5.1 // direct diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/images/logo.jpeg b/images/logo.jpeg new file mode 100644 index 0000000..200b7a6 Binary files /dev/null and b/images/logo.jpeg differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..c9bccb2 --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "net/http" + "os" + "strconv" + "time" + + "github.com/NickGowdy/deveui-cli/client" + "github.com/NickGowdy/deveui-cli/processor" + "github.com/joho/godotenv" +) + +/* +deveui-cli is a Go CLI program. +It is used for concurrently generating unique 16-character (hex) identifier called a DevEUI. +These are generated by the program and registered via an external (LoRaWAN) API. + +Usage: + + go run main.go (locally) + + go run deveui-cli (docker) + +Once this program starts, it will listen to syscall.SIGTERM, syscall.SIGINT via a channel. +This is to handle any unexpected terminations of the program and to resume processing DevEUIs. +*/ +func main() { + if err := godotenv.Load(".env"); err != nil { + panic("error loading.env file") + } + + baseurl := os.Getenv("BASE_URL") + + maxConcurrentJobs, err := strconv.Atoi(os.Getenv("MAX_CONCURRENT_JOBS")) + if err != nil { + panic("error parsing MAX_CONCURRENT_JOBS to int") + } + + codeRegistrationLimit, err := strconv.Atoi(os.Getenv("CODE_REGISTRATION_LIMIT")) + if err != nil { + panic("error parsing CODE_REGISTRATION_LIMIT to int") + } + + timeout, err := strconv.Atoi(os.Getenv("TIMEOUT")) + if err != nil { + panic("error parsing TIMEOUT to int") + } + + // setup client for requests + httpClient := &http.Client{ + Timeout: time.Second * time.Duration(timeout), + } + loraWAN := client.NewLoraWAN(baseurl, httpClient) + + // setup processor to do work + processor := &processor.Processor{ + CodeRegistrationLimit: codeRegistrationLimit, + MaxConcurrentJobs: maxConcurrentJobs, + LoraWAN: *loraWAN, + } + + ctx, cancel := context.WithCancel(context.Background()) + processor.Start(ctx, cancel) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..88cec9f --- /dev/null +++ b/main_test.go @@ -0,0 +1,7 @@ +package main + +import "testing" + +func BenchmarkMain(b *testing.B) { + main() +} diff --git a/processor/code_processor.go b/processor/code_processor.go new file mode 100644 index 0000000..143c881 --- /dev/null +++ b/processor/code_processor.go @@ -0,0 +1,58 @@ +package processor + +import ( + "context" + "log" + + "github.com/NickGowdy/deveui-cli/client" +) + +type Processor struct { + CodeRegistrationLimit int + MaxConcurrentJobs int + LoraWAN client.LoraWAN +} + +func (p *Processor) Start(ctx context.Context, cancel context.CancelFunc) { + // workCh := make(chan struct{}) + count := 0 + + for count < p.CodeRegistrationLimit { + device, err := p.LoraWAN.RegisterDevice(ctx) + if err != nil { + log.Print(err) + } else { + device.Print() + count++ + } + } + + // go func(ctx context.Context) { + // for { + // p.doWork(ctx, workCh) + // } + // }(ctx) + + // for { + // select { + // case <-ctx.Done(): + // return + // case <-workCh: + // count++ + // if count == p.CodeRegistrationLimit { + // cancel() + // fmt.Printf("work complete \n") + // } + // } + // } +} + +// func (cp *Processor) doWork(ctx context.Context, workCh chan<- struct{}) { +// device, err := cp.LoraWAN.RegisterDevice(ctx) +// if err != nil { +// return +// } else { +// device.Print() +// workCh <- struct{}{} +// } +// } diff --git a/processor/code_processor_test.go b/processor/code_processor_test.go new file mode 100644 index 0000000..84334f2 --- /dev/null +++ b/processor/code_processor_test.go @@ -0,0 +1,87 @@ +package processor + +import ( + "net/http" +) + +type MockClient struct { + DoFunc func(*http.Request) (resp *http.Response, err error) +} + +const ( + MAX_CONCURRENT_JOBS = 2 + CODE_REGISTRATION_LIMIT = 10 +) + +// func TestCanProcessCodes(t *testing.T) { +// mockClient := &MockClient{ +// DoFunc: func(*http.Request) (resp *http.Response, err error) { +// return &http.Response{}, nil +// }, +// } + +// loraWAN := client.NewLoraWAN("www.example.com", mockClient) + +// codeProcessor := &CodeProcessor{ +// CodeRegistrationLimit: CODE_REGISTRATION_LIMIT, +// MaxConcurrentJobs: MAX_CONCURRENT_JOBS, +// DeviceCh: make(chan device.Device), +// LoraWAN: *loraWAN, +// } + +// ctx, cancel := context.WithCancel(context.Background()) +// defer cancel() + +// work := make(chan struct{}, MAX_CONCURRENT_JOBS) + +// go func() { +// for { +// work <- struct{}{} +// } +// }() + +// // Spawn workers +// for j := 0; j < MAX_CONCURRENT_JOBS; j++ { +// go codeProcessor.Worker(ctx, work) +// } + +// n := 0 +// for d := range codeProcessor.DeviceCh { + +// identifier := d.GetIdentifier() +// code := d.GetCode() + +// if code == "" { +// t.Error("code should not be nil") +// } + +// if identifier == "" { +// t.Error("identifier should not be nil") +// } + +// if identifier[len(identifier)-5:] != code { +// t.Errorf("code should be last 5 characters of identifier, but is %s", code) +// } + +// if len(identifier) != 16 { +// t.Errorf("identifier should be exactly 16 characters, but is %d", len(identifier)) +// } + +// if len(code) != 5 { +// t.Errorf("code should be exactly 5 characters, but is %d", len(code)) +// } + +// n += 1 +// if n == CODE_REGISTRATION_LIMIT { +// break +// } +// } +// } + +// func (m *MockClient) Do(*http.Request) (resp *http.Response, err error) { +// return &http.Response{ +// StatusCode: http.StatusOK, +// Body: io.NopCloser(bytes.NewReader(nil)), +// Status: "200 OK"}, +// nil +// }