diff --git a/.gitignore b/.gitignore
index 7e00889..9a22709 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
# Built binaries
-etime
+/etime
dist/*
# Development leftovers
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70d3a45..114ed94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ to [Semantic Versioning][semver].
- Set the `go` version to a fixed `1.22.6` for the toolchain
- Update token authentication to use the latest Cognito SDK
- Test coverage as a metric to at least consider with changes
+- Separate token exchanges into a cognito package for testing
## [1.1.0] - 2024-08-03
diff --git a/cmd/etime.go b/cmd/etime.go
deleted file mode 100644
index 4b5b476..0000000
--- a/cmd/etime.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package etime
-
-import (
- "os/exec"
-
- "github.com/zimeg/emporia-time/internal/program"
- "github.com/zimeg/emporia-time/pkg/emporia"
- "github.com/zimeg/emporia-time/pkg/energy"
- "github.com/zimeg/emporia-time/pkg/times"
-)
-
-// CommandResult holds information from the run command
-type CommandResult struct {
- energy.EnergyResult
- times.TimeMeasurement
- ExitCode int
-}
-
-// Setup prepares the command and client with provided inputs
-func Setup(arguments []string) (command program.Command, client emporia.Emporia, err error) {
- command, err = program.ParseFlags(arguments)
- if err != nil || command.Flags.Help || command.Flags.Version {
- return command, client, err
- }
- if config, err := emporia.SetupConfig(command.Flags); err != nil {
- return command, client, err
- } else {
- client.Config = config
- }
- return command, client, err
-}
-
-// Run executes the command and returns the usage statistics
-func Run(command program.Command, client emporia.Emporia) (results CommandResult, err error) {
- if measurements, err := times.TimeExec(command); err != nil {
- if exitError, ok := err.(*exec.ExitError); ok {
- results.ExitCode = exitError.ExitCode()
- } else {
- return results, err
- }
- results.TimeMeasurement = measurements
- } else {
- results.TimeMeasurement = measurements
- }
- if usage, err := client.CollectEnergyUsage(results.TimeMeasurement); err != nil {
- return results, err
- } else {
- results.EnergyResult = usage
- }
- return results, nil
-}
diff --git a/cmd/etime/etime.go b/cmd/etime/etime.go
new file mode 100644
index 0000000..d81115e
--- /dev/null
+++ b/cmd/etime/etime.go
@@ -0,0 +1,45 @@
+package etime
+
+import (
+ "fmt"
+ "os/exec"
+
+ "github.com/zimeg/emporia-time/pkg/config"
+ "github.com/zimeg/emporia-time/pkg/energy"
+ "github.com/zimeg/emporia-time/pkg/times"
+)
+
+// CommandResult holds information from the run command
+type CommandResult struct {
+ energy.EnergyResult
+ times.TimeMeasurement
+ ExitCode int
+}
+
+// Run executes the command and returns the usage statistics
+func Run(cmd []string, cfg config.Configure) (results CommandResult, err error) {
+ available, err := cfg.API().Status()
+ if err != nil {
+ return CommandResult{}, err
+ } else if !available {
+ return CommandResult{}, fmt.Errorf("Error: Cannot measure energy during Emporia maintenance")
+ }
+ measurements, err := times.TimeExec(cmd)
+ if err != nil {
+ if exitError, ok := err.(*exec.ExitError); ok {
+ results.ExitCode = exitError.ExitCode()
+ } else {
+ return CommandResult{}, err
+ }
+ results.TimeMeasurement = measurements
+ } else {
+ results.TimeMeasurement = measurements
+ }
+ usage, err := cfg.API().GetChartUsage(results.TimeMeasurement)
+ if err != nil {
+ return results, err
+ } else {
+ results.EnergyResult = usage
+ }
+ return results, nil
+}
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644
index 0000000..f33bdfb
--- /dev/null
+++ b/cmd/root.go
@@ -0,0 +1,53 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/spf13/afero"
+ "github.com/zimeg/emporia-time/cmd/etime"
+ "github.com/zimeg/emporia-time/internal/display/templates"
+ "github.com/zimeg/emporia-time/pkg/api"
+ "github.com/zimeg/emporia-time/pkg/cognito"
+ "github.com/zimeg/emporia-time/pkg/config"
+)
+
+// Root facilitates the setup and execution of the command
+func Root(
+ ctx context.Context,
+ cog cognito.Cognitoir,
+ fs afero.Fs,
+ req api.Emporiac,
+ args []string,
+ version string,
+) (
+ etime.CommandResult,
+ error,
+) {
+ cmd, flags, err := config.ParseFlags(args)
+ if err != nil {
+ return etime.CommandResult{}, err
+ } else if flags.Version {
+ fmt.Printf("%s\n", version)
+ return etime.CommandResult{}, nil
+ } else if flags.Help {
+ templates.PrintHelpMessage()
+ return etime.CommandResult{}, nil
+ }
+ cfg, err := config.Load(ctx, cog, fs, req, flags)
+ if err != nil {
+ return etime.CommandResult{}, err
+ }
+ results, err := etime.Run(cmd, &cfg)
+ if err != nil {
+ return etime.CommandResult{}, err
+ }
+ stats, err := templates.FormatUsage(results, flags.Portable)
+ if err != nil {
+ return results, err
+ } else {
+ fmt.Fprintf(os.Stderr, "%s\n", stats)
+ }
+ return results, nil
+}
diff --git a/go.mod b/go.mod
index 3e575ae..eda9a27 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go-v2/config v1.27.31
github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.43.3
+ github.com/spf13/afero v1.11.0
github.com/stretchr/testify v1.9.0
)
@@ -28,8 +29,9 @@ require (
github.com/mattn/go-isatty v0.0.8 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- golang.org/x/sys v0.5.0 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
+ golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.1.0 // indirect
- golang.org/x/text v0.13.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 277b1bb..691c9ee 100644
--- a/go.sum
+++ b/go.sum
@@ -47,7 +47,11 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@@ -66,8 +70,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
@@ -76,8 +80,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/main.go b/main.go
index 14128bc..886e16d 100644
--- a/main.go
+++ b/main.go
@@ -1,43 +1,36 @@
package main
import (
- "fmt"
+ "context"
"log"
"os"
- etime "github.com/zimeg/emporia-time/cmd"
- "github.com/zimeg/emporia-time/internal/display/templates"
- "github.com/zimeg/emporia-time/pkg/emporia"
+ "github.com/spf13/afero"
+ "github.com/zimeg/emporia-time/cmd"
+ "github.com/zimeg/emporia-time/pkg/api"
+ "github.com/zimeg/emporia-time/pkg/cognito"
)
// version is the title of this current build
var version = "development"
+const (
+ clientID string = "4qte47jbstod8apnfic0bunmrq" // Emporia AWS Cognito client ID
+ region string = "us-east-2" // Emporia AWS region
+)
+
// main manages the lifecycle of this program
func main() {
- command, client, err := etime.Setup(os.Args)
+ ctx := context.Background()
+ fs := afero.NewOsFs()
+ req := api.New()
+ cog, err := cognito.NewClient(ctx, clientID, region)
if err != nil {
log.Fatalf("Error: %s", err)
- } else if command.Flags.Version {
- fmt.Printf("%s\n", version)
- os.Exit(0)
- } else if command.Flags.Help {
- templates.PrintHelpMessage()
- os.Exit(0)
- }
- if available, err := emporia.EmporiaStatus(); err != nil {
- log.Fatalf("Error: %s", err)
- } else if !available {
- log.Fatalf("Error: Cannot measure energy during Emporia maintenance\n")
}
- results, err := etime.Run(command, client)
+ result, err := cmd.Root(ctx, cog, fs, req, os.Args, version)
if err != nil {
log.Fatalf("Error: %s", err)
}
- if stats, err := templates.FormatUsage(results, command.Flags.Portable); err != nil {
- log.Fatalf("Error: %s", err)
- } else {
- fmt.Fprintf(os.Stderr, "%s\n", stats)
- }
- os.Exit(results.ExitCode)
+ os.Exit(result.ExitCode)
}
diff --git a/pkg/api/api.go b/pkg/api/api.go
new file mode 100644
index 0000000..3b6539a
--- /dev/null
+++ b/pkg/api/api.go
@@ -0,0 +1,71 @@
+package api
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+
+ "github.com/zimeg/emporia-time/pkg/energy"
+ "github.com/zimeg/emporia-time/pkg/times"
+)
+
+// Emporiac communicates with the Emporia API
+type Emporiac interface {
+ GetChartUsage(times times.TimeMeasurement) (results energy.EnergyResult, err error)
+ GetCustomerDevices() (devices []Device, err error)
+ Status() (available bool, err error)
+
+ SetDevice(deviceID string)
+ SetToken(token string)
+}
+
+// Emporia holds information for and from the Emporia API
+type Emporia struct {
+ client interface {
+ // Do does the HTTP request and is often implmented using net/http
+ Do(req *http.Request) (*http.Response, error)
+ }
+ deviceID string
+ token string
+}
+
+// RequestURL is the base URL of the API
+const RequestURL string = "https://api.emporiaenergy.com"
+
+// New creates a new client to interact with Emporia HTTP APIs
+func New() *Emporia {
+ return &Emporia{
+ client: &http.Client{},
+ }
+}
+
+// SetToken sets the token for the client
+func (emp *Emporia) SetToken(token string) {
+ emp.token = token
+}
+
+// SetDevice sets the device ID for the client
+func (emp *Emporia) SetDevice(deviceID string) {
+ emp.deviceID = deviceID
+}
+
+// get makes an authenticated GET request to URL and saves the response to data
+func (emp *Emporia) get(url string, data any) error {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return err
+ }
+ if emp.token != "" {
+ req.Header.Add("authToken", emp.token)
+ }
+ resp, err := emp.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(body, &data)
+}
diff --git a/pkg/api/api_mock.go b/pkg/api/api_mock.go
new file mode 100644
index 0000000..8443890
--- /dev/null
+++ b/pkg/api/api_mock.go
@@ -0,0 +1,17 @@
+package api
+
+import (
+ "github.com/stretchr/testify/mock"
+)
+
+type EmporiaMock struct {
+ mock.Mock
+}
+
+func (em *EmporiaMock) SetDevice(deviceID string) {
+ em.Called(deviceID)
+}
+
+func (em *EmporiaMock) SetToken(token string) {
+ em.Called(token)
+}
diff --git a/pkg/api/devices.go b/pkg/api/devices.go
new file mode 100644
index 0000000..3c94e80
--- /dev/null
+++ b/pkg/api/devices.go
@@ -0,0 +1,33 @@
+package api
+
+import (
+ "fmt"
+)
+
+// Device represents a device that can be measured
+type Device struct {
+ DeviceGid int
+ LocationProperties struct {
+ DeviceName string
+ }
+}
+
+// DeviceResponse contains a slice of available devices
+type DeviceResponse struct {
+ Devices []Device
+ Message string
+}
+
+// GetCustomerDevices returns customer devices for the Emporia account
+func (emp *Emporia) GetCustomerDevices() ([]Device, error) {
+ response := DeviceResponse{}
+ url := fmt.Sprintf("%s/customers/devices", RequestURL)
+ err := emp.get(url, &response)
+ if err != nil {
+ return []Device{}, err
+ }
+ if response.Message != "" {
+ return []Device{}, fmt.Errorf("%s", response.Message)
+ }
+ return response.Devices, nil
+}
diff --git a/pkg/api/devices_mock.go b/pkg/api/devices_mock.go
new file mode 100644
index 0000000..d615101
--- /dev/null
+++ b/pkg/api/devices_mock.go
@@ -0,0 +1,6 @@
+package api
+
+func (em *EmporiaMock) GetCustomerDevices() (devices []Device, err error) {
+ args := em.Called()
+ return args.Get(0).([]Device), args.Error(1)
+}
diff --git a/pkg/api/status.go b/pkg/api/status.go
new file mode 100644
index 0000000..145dfa7
--- /dev/null
+++ b/pkg/api/status.go
@@ -0,0 +1,21 @@
+package api
+
+import (
+ "net/http"
+)
+
+// StatusURL points to a file that appears during maintenance
+const StatusURL string = "https://s3.amazonaws.com/com.emporiaenergy.manual.ota/maintenance/maintenance.json"
+
+// Status returns if the Emporia API is available
+//
+// https://github.com/magico13/PyEmVue/blob/master/api_docs.md#detection-of-maintenance
+func (emp *Emporia) Status() (bool, error) {
+ resp, err := http.Get(StatusURL)
+ if err != nil {
+ return false, err
+ }
+ defer resp.Body.Close()
+ status := resp.StatusCode == 403
+ return status, nil
+}
diff --git a/pkg/api/status_mock.go b/pkg/api/status_mock.go
new file mode 100644
index 0000000..18d10db
--- /dev/null
+++ b/pkg/api/status_mock.go
@@ -0,0 +1,6 @@
+package api
+
+func (em *EmporiaMock) Status() (available bool, err error) {
+ args := em.Called()
+ return args.Bool(0), args.Error(1)
+}
diff --git a/pkg/api/usage.go b/pkg/api/usage.go
new file mode 100644
index 0000000..b0f44e3
--- /dev/null
+++ b/pkg/api/usage.go
@@ -0,0 +1,84 @@
+package api
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net/url"
+ "time"
+
+ "github.com/zimeg/emporia-time/pkg/energy"
+ "github.com/zimeg/emporia-time/pkg/times"
+)
+
+// UsageResponse holds usage information from the response
+type UsageResponse struct {
+ Message string
+ FirstUsageInstant string
+ UsageList []float64
+}
+
+// GetChartUsage calls the Emporia API for usage information over and over until
+// a certain confidence is reached
+func (emp *Emporia) GetChartUsage(times times.TimeMeasurement) (energy.EnergyResult, error) {
+ confidence := 0.80
+
+ // Delay before lookup to respect latency
+ time.Sleep(200 * time.Millisecond)
+ chart, err := emp.LookupEnergyUsage(times)
+ if err != nil {
+ log.Printf("Error: Failed to gather energy usage data!\n")
+ return energy.EnergyResult{}, err
+ }
+
+ results := energy.ExtrapolateUsage(energy.EnergyMeasurement{
+ Chart: chart,
+ Duration: times.Elapsed,
+ })
+
+ // Repeat lookup for unsure results
+ for results.Sureness < confidence {
+ results, err = emp.GetChartUsage(times)
+ if err != nil {
+ return energy.EnergyResult{}, err
+ }
+ }
+ return results, nil
+}
+
+// LookupEnergyUsage gathers device watt usage between the start and end times
+func (emp *Emporia) LookupEnergyUsage(times times.TimeMeasurement) ([]float64, error) {
+ response, err := emp.getEnergyUsage(times)
+ if err != nil {
+ return []float64{}, err
+ } else if response.Message != "" {
+ return []float64{}, errors.New(response.Message)
+ }
+ chart := response.UsageList
+ for ii, kwh := range chart {
+ chart[ii] = energy.ScaleKWhToWs(kwh)
+ }
+ return chart, nil
+}
+
+// getEnergyUsage performs a GET request to collect usage statistics
+//
+// https://github.com/magico13/PyEmVue/blob/master/api_docs.md#getchartusage---usage-over-a-range-of-time
+func (emp *Emporia) getEnergyUsage(times times.TimeMeasurement) (UsageResponse, error) {
+ response := UsageResponse{}
+ params := url.Values{
+ "apiMethod": []string{"getChartUsage"},
+ "deviceGid": []string{emp.deviceID},
+ "channel": []string{"1,2,3"}, // ?
+ "start": []string{times.Start.Format(time.RFC3339)},
+ "end": []string{times.End.Format(time.RFC3339)},
+ "scale": []string{"1S"},
+ "energyUnit": []string{"KilowattHours"},
+ }
+ url := fmt.Sprintf("%s/AppAPI?%s", RequestURL, params.Encode())
+ err := emp.get(url, &response)
+ if err != nil {
+ return UsageResponse{}, err
+ }
+ return response, nil
+}
diff --git a/pkg/api/usage_mock.go b/pkg/api/usage_mock.go
new file mode 100644
index 0000000..eaee1ba
--- /dev/null
+++ b/pkg/api/usage_mock.go
@@ -0,0 +1,11 @@
+package api
+
+import (
+ "github.com/zimeg/emporia-time/pkg/energy"
+ "github.com/zimeg/emporia-time/pkg/times"
+)
+
+func (em *EmporiaMock) GetChartUsage(times times.TimeMeasurement) (results energy.EnergyResult, err error) {
+ args := em.Called(times)
+ return args.Get(0).(energy.EnergyResult), args.Error(1)
+}
diff --git a/pkg/cognito/cognito.go b/pkg/cognito/cognito.go
new file mode 100644
index 0000000..638c244
--- /dev/null
+++ b/pkg/cognito/cognito.go
@@ -0,0 +1,87 @@
+package cognito
+
+import (
+ "context"
+
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ awscognito "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
+)
+
+// CognitoResponse holds the authentication information from Cognito
+type CognitoResponse struct {
+ IdToken *string
+ RefreshToken *string
+ ExpiresIn int32
+}
+
+// Cognitoir suggests expected interactions around authentication
+type Cognitoir interface {
+ GenerateTokens(ctx context.Context, username string, password string) (CognitoResponse, error)
+ RefreshTokens(ctx context.Context, refreshToken string) (CognitoResponse, error)
+}
+
+// Cognito implements the interactions for authentication
+type Cognito struct {
+ clientID string
+ client *awscognito.Client
+}
+
+// Create a cognito client with customized configurations
+func NewClient(ctx context.Context, clientID string, region string) (*Cognito, error) {
+ config, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ if err != nil {
+ return &Cognito{}, err
+ }
+ client := awscognito.NewFromConfig(config)
+ return &Cognito{
+ clientID: clientID,
+ client: client,
+ }, nil
+}
+
+// GenerateTokens creates new auth tokens from credentials
+func (cog *Cognito) GenerateTokens(ctx context.Context, username string, password string) (
+ CognitoResponse,
+ error,
+) {
+ auth := awscognito.InitiateAuthInput{
+ AuthFlow: "USER_PASSWORD_AUTH",
+ AuthParameters: map[string]string{
+ "USERNAME": username,
+ "PASSWORD": password,
+ },
+ ClientId: &cog.clientID,
+ }
+ user, err := cog.client.InitiateAuth(ctx, &auth)
+ if err != nil {
+ return CognitoResponse{}, err
+ }
+ return CognitoResponse{
+ IdToken: user.AuthenticationResult.IdToken,
+ RefreshToken: user.AuthenticationResult.RefreshToken,
+ ExpiresIn: user.AuthenticationResult.ExpiresIn,
+ }, nil
+}
+
+// RefreshTokens regenerates auth tokens from the refresh token
+func (cog *Cognito) RefreshTokens(ctx context.Context, refreshToken string) (
+ CognitoResponse,
+ error,
+) {
+ auth := awscognito.InitiateAuthInput{
+ AuthFlow: "REFRESH_TOKEN_AUTH",
+ AuthParameters: map[string]string{
+ "REFRESH_TOKEN": refreshToken,
+ },
+ ClientId: &cog.clientID,
+ }
+ user, err := cog.client.InitiateAuth(ctx, &auth)
+ if err != nil {
+ return CognitoResponse{}, err
+ }
+ return CognitoResponse{
+ IdToken: user.AuthenticationResult.IdToken,
+ RefreshToken: user.AuthenticationResult.RefreshToken,
+ ExpiresIn: user.AuthenticationResult.ExpiresIn,
+ }, nil
+}
diff --git a/pkg/cognito/cognito_mock.go b/pkg/cognito/cognito_mock.go
new file mode 100644
index 0000000..d4a6a33
--- /dev/null
+++ b/pkg/cognito/cognito_mock.go
@@ -0,0 +1,21 @@
+package cognito
+
+import (
+ "context"
+
+ "github.com/stretchr/testify/mock"
+)
+
+type CognitoMock struct {
+ mock.Mock
+}
+
+func (cm *CognitoMock) GenerateTokens(ctx context.Context, username string, password string) (CognitoResponse, error) {
+ args := cm.Called(ctx, username, password)
+ return args.Get(0).(CognitoResponse), args.Error(1)
+}
+
+func (cm *CognitoMock) RefreshTokens(ctx context.Context, refreshToken string) (CognitoResponse, error) {
+ args := cm.Called(ctx, refreshToken)
+ return args.Get(0).(CognitoResponse), args.Error(1)
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..a18f6fc
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,112 @@
+package config
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "time"
+
+ "github.com/spf13/afero"
+ "github.com/zimeg/emporia-time/pkg/api"
+ "github.com/zimeg/emporia-time/pkg/cognito"
+)
+
+// Configure reveals what configurations to the changing settings
+type Configure interface {
+ API() api.Emporiac
+}
+
+// Config contains device configurations and user authentications
+type Config struct {
+ Device string // Device is the specific machine to be measured
+ Tokens TokenSet // Tokens contain the authentication information
+
+ fs afero.Fs // fs wraps abstraction over a stable file system
+ path string // path is the location of the configuration file
+ req api.Emporiac // req is an HTTP client with some configurations
+}
+
+// TokenSet contains authentication information saved for cognito
+type TokenSet struct {
+ IdToken string
+ RefreshToken string
+ ExpiresAt time.Time
+}
+
+// Load collects configurations into a single structure
+func Load(
+ ctx context.Context,
+ cog cognito.Cognitoir,
+ fs afero.Fs,
+ req api.Emporiac,
+ flags Flags,
+) (
+ cfg Config,
+ err error,
+) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return Config{}, err
+ }
+ configDir := homeDir + "/.config/etime"
+ if val, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok {
+ configDir = val + "/etime"
+ }
+ err = fs.MkdirAll(configDir, 0o755)
+ if err != nil {
+ return Config{}, err
+ }
+ path := configDir + "/settings.json"
+ data, err := afero.ReadFile(fs, path)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ return Config{}, err
+ }
+ } else if len(data) > 0 {
+ err := json.Unmarshal(data, &cfg)
+ if err != nil {
+ return Config{}, err
+ }
+ }
+ cfg.fs = fs
+ cfg.path = path
+ cfg.req = req
+ defer func() {
+ if err != nil {
+ return
+ }
+ err = cfg.save()
+ }()
+ tokens, err := cfg.GetTokens(ctx, cog, flags)
+ if err != nil {
+ return Config{}, err
+ } else {
+ cfg.SetTokens(tokens)
+ }
+ device, err := cfg.GetDevice(flags)
+ if err != nil {
+ return Config{}, err
+ } else {
+ cfg.SetDevice(device)
+ }
+ return cfg, nil
+}
+
+// API returns a client for making requests
+func (cfg *Config) API() api.Emporiac {
+ return cfg.req
+}
+
+// Save writes configuration data to the settings file
+func (cfg *Config) save() error {
+ data, err := json.MarshalIndent(cfg, "", "\t")
+ if err != nil {
+ return err
+ }
+ err = afero.WriteFile(cfg.fs, cfg.path, data, 0o660)
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
new file mode 100644
index 0000000..5585382
--- /dev/null
+++ b/pkg/config/config_test.go
@@ -0,0 +1,125 @@
+package config
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "github.com/zimeg/emporia-time/pkg/api"
+ "github.com/zimeg/emporia-time/pkg/cognito"
+)
+
+func TestLoad(t *testing.T) {
+ mockIDToken := "eyJ-example-token"
+ mockRefreshToken := "eyJ-example-refresh"
+
+ tests := map[string]struct {
+ mockConfigFile string
+ mockFlags Flags
+ mockGenerateTokensResponse cognito.CognitoResponse
+ mockGenerateTokensError error
+ mockGetCustomerDevicesResponse []api.Device
+ mockGetCustomerDevicesError error
+ mockRefreshTokensResponse cognito.CognitoResponse
+ mockRefreshTokensError error
+ expectedConfig Config
+ expectedError error
+ }{
+ "loads the saved and valid credentials into configurations": {
+ mockConfigFile: `{
+ "Device": "123456",
+ "Tokens": {
+ "IdToken": "eyJ-example-001",
+ "RefreshToken": "eyJ-example-002",
+ "ExpiresAt": "2222-02-22T22:22:22Z"
+ }
+ }`,
+ mockGetCustomerDevicesResponse: []api.Device{
+ {
+ DeviceGid: 123456,
+ },
+ },
+ expectedConfig: Config{
+ Device: "123456",
+ Tokens: TokenSet{
+ IdToken: "eyJ-example-001",
+ RefreshToken: "eyJ-example-002",
+ ExpiresAt: time.Date(2222, 2, 22, 22, 22, 22, 0, time.UTC),
+ },
+ },
+ },
+ "writes configured authentication from provided credentials": {
+ mockFlags: Flags{
+ Username: "watt@example.com",
+ Password: "joules123",
+ },
+ mockGenerateTokensResponse: cognito.CognitoResponse{
+ IdToken: &mockIDToken,
+ RefreshToken: &mockRefreshToken,
+ ExpiresIn: 1,
+ },
+ mockGetCustomerDevicesResponse: []api.Device{
+ {
+ DeviceGid: 1000001,
+ },
+ },
+ expectedConfig: Config{
+ Device: "1000001",
+ Tokens: TokenSet{
+ IdToken: mockIDToken,
+ RefreshToken: mockRefreshToken,
+ },
+ },
+ },
+ }
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ fs := afero.NewMemMapFs()
+ cog := &cognito.CognitoMock{}
+ cog.On("GenerateTokens", mock.Anything, mock.Anything, mock.Anything).
+ Return(tt.mockGenerateTokensResponse, tt.mockGenerateTokensError)
+ cog.On("RefreshTokens", mock.Anything, mock.Anything).
+ Return(tt.mockRefreshTokensResponse, tt.mockRefreshTokensError)
+ req := &api.EmporiaMock{}
+ req.On("GetCustomerDevices").
+ Return(tt.mockGetCustomerDevicesResponse, tt.mockGetCustomerDevicesError)
+ req.On("SetToken", mock.Anything)
+ req.On("SetDevice", mock.Anything)
+ dir, err := os.UserHomeDir()
+ require.NoError(t, err)
+ if tt.mockConfigFile != "" {
+ settings, err := fs.Create(dir + "/.config/etime/settings.json")
+ require.NoError(t, err)
+ _, err = settings.WriteString(tt.mockConfigFile)
+ require.NoError(t, err)
+ }
+ os.Setenv("EMPORIA_USERNAME", tt.mockFlags.Username) // FIXME: use flags!
+ os.Setenv("EMPORIA_PASSWORD", tt.mockFlags.Password) // FIXME: use flags!
+ cfg, err := Load(ctx, cog, fs, req, tt.mockFlags)
+ if tt.expectedError != nil {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedConfig.Device, cfg.Device)
+ assert.Equal(t, tt.expectedConfig.Tokens.IdToken, cfg.Tokens.IdToken)
+ assert.Equal(t, tt.expectedConfig.Tokens.RefreshToken, cfg.Tokens.RefreshToken)
+ assert.Greater(t, cfg.Tokens.ExpiresAt, time.Now())
+ assert.Equal(t, dir+"/.config/etime/settings.json", cfg.path)
+ req.AssertCalled(t, "SetDevice", tt.expectedConfig.Device)
+ req.AssertCalled(t, "SetToken", tt.expectedConfig.Tokens.IdToken)
+ actualConfigFile, err := afero.ReadFile(fs, cfg.path)
+ require.NoError(t, err)
+ expectedConfigFile, err := json.MarshalIndent(cfg, "", "\t")
+ require.NoError(t, err)
+ assert.Equal(t, expectedConfigFile, actualConfigFile)
+ }
+ })
+ }
+}
diff --git a/pkg/config/credentials.go b/pkg/config/credentials.go
new file mode 100644
index 0000000..a0dac8e
--- /dev/null
+++ b/pkg/config/credentials.go
@@ -0,0 +1,109 @@
+package config
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/zimeg/emporia-time/internal/terminal"
+ "github.com/zimeg/emporia-time/pkg/cognito"
+)
+
+// Credentials contains basic authentication information for gathering tokens
+type Credentials struct {
+ Username string
+ Password string
+}
+
+// SetTokens stores newly gathered auth tokens in the config
+func (config *Config) SetTokens(auth cognito.CognitoResponse) {
+ if auth.IdToken != nil {
+ token := *auth.IdToken
+ config.Tokens.IdToken = token
+ config.req.SetToken(token)
+ } else {
+ config.req.SetToken(config.Tokens.IdToken)
+ return
+ }
+ if auth.RefreshToken != nil {
+ config.Tokens.RefreshToken = *auth.RefreshToken
+ }
+ config.Tokens.ExpiresAt = time.Now().
+ Add(time.Second * time.Duration(auth.ExpiresIn)).UTC()
+}
+
+// GetTokens gathers valid authentication tokens from provided configurations
+func (cfg *Config) GetTokens(
+ ctx context.Context,
+ cog cognito.Cognitoir,
+ flags Flags,
+) (
+ cognito.CognitoResponse,
+ error,
+) {
+ if cfg.useCredentials(flags) {
+ username, password, err := cfg.gatherCredentials(flags)
+ if err != nil {
+ return cognito.CognitoResponse{}, err
+ }
+ tokens, err := cog.GenerateTokens(ctx, username, password)
+ if err != nil {
+ return cognito.CognitoResponse{}, err
+ }
+ return tokens, nil
+ }
+ if time.Now().After(cfg.Tokens.ExpiresAt) {
+ tokens, err := cog.RefreshTokens(ctx, cfg.Tokens.RefreshToken)
+ if err != nil {
+ return cognito.CognitoResponse{}, err
+ }
+ return tokens, nil
+ }
+ return cognito.CognitoResponse{}, nil
+}
+
+// useCredentials returns if new login credentials should be used
+func (cfg *Config) useCredentials(flags Flags) bool {
+ return (cfg.Tokens.IdToken == "" || cfg.Tokens.RefreshToken == "") ||
+ (flags.Username != "" || os.Getenv("EMPORIA_USERNAME") != "") ||
+ (flags.Password != "" || os.Getenv("EMPORIA_PASSWORD") != "")
+}
+
+// headlessLogin returns if all credentials are provided by flag or environment
+func (cfg *Config) headlessLogin(flags Flags) bool {
+ return (flags.Username != "" || os.Getenv("EMPORIA_USERNAME") != "") &&
+ (flags.Password != "" || os.Getenv("EMPORIA_PASSWORD") != "")
+}
+
+// gatherCredentials prompts for an Emporia username and password
+func (cfg *Config) gatherCredentials(
+ flags Flags,
+) (
+ username string,
+ password string,
+ err error,
+) {
+ if !cfg.headlessLogin(flags) {
+ fmt.Printf("Enter your Emporia credentials \n")
+ }
+ username, err = terminal.CollectInput(&terminal.Prompt{
+ Message: "Username",
+ Flag: flag.Lookup("username"),
+ Environment: "EMPORIA_USERNAME",
+ })
+ if err != nil {
+ return "", "", err
+ }
+ password, err = terminal.CollectInput(&terminal.Prompt{
+ Message: "Password",
+ Flag: flag.Lookup("password"),
+ Environment: "EMPORIA_PASSWORD",
+ Hidden: true,
+ })
+ if err != nil {
+ return "", "", err
+ }
+ return username, password, nil
+}
diff --git a/pkg/config/devices.go b/pkg/config/devices.go
new file mode 100644
index 0000000..aae1fbf
--- /dev/null
+++ b/pkg/config/devices.go
@@ -0,0 +1,70 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+
+ "github.com/zimeg/emporia-time/internal/terminal"
+)
+
+// Device is the machine being measured
+type Device struct {
+ DeviceID string // DeviceID is the unique numeric identifier
+}
+
+// SetDevice stores the active device throughout configurations
+func (cfg *Config) SetDevice(device Device) {
+ deviceID := device.DeviceID
+ cfg.Device = deviceID
+ cfg.req.SetDevice(deviceID)
+}
+
+// gatherDevice prompts and stores the choice of an Emporia device
+func (cfg *Config) GetDevice(flags Flags) (Device, error) {
+ names, gids, gidLabels := []string{}, []string{}, []string{}
+ device := ""
+ devices, err := cfg.API().GetCustomerDevices()
+ if err != nil {
+ return Device{}, err
+ }
+ if len(devices) == 0 {
+ return Device{}, errors.New("No available devices found!")
+ }
+ switch {
+ case flags.Device != "":
+ device = flags.Device
+ case os.Getenv("EMPORIA_DEVICE") != "":
+ device = os.Getenv("EMPORIA_DEVICE")
+ case cfg.Device != "":
+ device = cfg.Device
+ }
+ for _, val := range devices {
+ deviceGid := strconv.Itoa(val.DeviceGid)
+ if deviceGid == device || val.LocationProperties.DeviceName == device {
+ response := Device{
+ DeviceID: strconv.Itoa(val.DeviceGid),
+ }
+ return response, nil
+ }
+ names = append(names, val.LocationProperties.DeviceName)
+ gids = append(gids, deviceGid)
+ gidLabels = append(gidLabels, fmt.Sprintf("#%d", val.DeviceGid))
+ }
+ if device != "" {
+ return Device{}, errors.New("No matching device found!")
+ }
+ selection, err := terminal.CollectSelect(terminal.Prompt{
+ Message: "Select a device:",
+ Options: names,
+ Descriptions: gidLabels,
+ })
+ if err != nil {
+ return Device{}, err
+ }
+ response := Device{
+ DeviceID: gids[selection],
+ }
+ return response, nil
+}
diff --git a/internal/program/flags.go b/pkg/config/flags.go
similarity index 69%
rename from internal/program/flags.go
rename to pkg/config/flags.go
index b719f49..0d1aeec 100644
--- a/internal/program/flags.go
+++ b/pkg/config/flags.go
@@ -1,4 +1,4 @@
-package program
+package config
import (
"bytes"
@@ -15,16 +15,9 @@ type Flags struct {
Version bool
}
-// Command contains the command line configurations
-type Command struct {
- Args []string // Args contains arguments to use in the provided program
- Flags Flags // Flags holds command specific flags and configurations
-}
-
// ParseFlags prepares the command using provided arguments
-func ParseFlags(arguments []string) (Command, error) {
+func ParseFlags(args []string) (cmd []string, flags Flags, err error) {
flagset := flag.NewFlagSet("etime", flag.ContinueOnError)
- var flags Flags
flagset.BoolVar(&flags.Help, "h", false, "display this very informative message")
flagset.BoolVar(&flags.Help, "help", false, "display this very informative message")
@@ -37,13 +30,13 @@ func ParseFlags(arguments []string) (Command, error) {
flagset.StringVar(&flags.Username, "username", "", "account username for Emporia")
flagset.SetOutput(&bytes.Buffer{})
- err := flagset.Parse(arguments[1:])
+ err = flagset.Parse(args[1:])
if err != nil {
- return Command{}, err
+ return []string{}, Flags{}, err
}
- commandArgs := flagset.Args()
- if len(commandArgs) <= 0 {
+ cmd = flagset.Args()
+ if len(cmd) <= 0 {
flags.Help = true
}
- return Command{Args: commandArgs, Flags: flags}, nil
+ return cmd, flags, nil
}
diff --git a/internal/program/flags_test.go b/pkg/config/flags_test.go
similarity index 60%
rename from internal/program/flags_test.go
rename to pkg/config/flags_test.go
index 374bae7..3ce6687 100644
--- a/internal/program/flags_test.go
+++ b/pkg/config/flags_test.go
@@ -1,4 +1,4 @@
-package program
+package config
import (
"fmt"
@@ -10,86 +10,68 @@ import (
func TestParseFlags(t *testing.T) {
tests := map[string]struct {
Arguments []string
- Command Command
+ Command []string
+ Flags Flags
Error error
}{
"plain arguments are treated as a command": {
Arguments: []string{"etime", "sleep", "12"},
- Command: Command{
- Args: []string{"sleep", "12"},
- },
+ Command: []string{"sleep", "12"},
},
"flags before the command are parsed as flags": {
Arguments: []string{"etime", "-p", "make", "build"},
- Command: Command{
- Args: []string{"make", "build"},
- Flags: Flags{Portable: true},
- },
+ Command: []string{"make", "build"},
+ Flags: Flags{Portable: true},
},
"flags after the command are parsed as command": {
Arguments: []string{"etime", "zip", "code.zip", "-r", "."},
- Command: Command{
- Args: []string{"zip", "code.zip", "-r", "."},
- },
+ Command: []string{"zip", "code.zip", "-r", "."},
},
"overlapping command flags are for the command": {
Arguments: []string{"etime", "unzip", "-p", "code.zip"},
- Command: Command{
- Args: []string{"unzip", "-p", "code.zip"},
- },
+ Command: []string{"unzip", "-p", "code.zip"},
},
"duplicated flags that matched are set separate": {
Arguments: []string{"etime", "-p", "mkdir", "-p", "/tmp/words"},
- Command: Command{
- Args: []string{"mkdir", "-p", "/tmp/words"},
- Flags: Flags{Portable: true},
- },
+ Command: []string{"mkdir", "-p", "/tmp/words"},
+ Flags: Flags{Portable: true},
},
"multiple energy flags are accepted at a time": {
Arguments: []string{"etime", "--username", "example", "--password", "123", "ls"},
- Command: Command{
- Args: []string{"ls"},
- Flags: Flags{Username: "example", Password: "123"},
- },
+ Command: []string{"ls"},
+ Flags: Flags{Username: "example", Password: "123"},
},
"help is noticed when help flags are provided": {
Arguments: []string{"etime", "-h"},
- Command: Command{
- Args: []string{},
- Flags: Flags{Help: true},
- },
+ Command: []string{},
+ Flags: Flags{Help: true},
},
"help is noticed when no arguments are provided": {
Arguments: []string{"etime"},
- Command: Command{
- Args: []string{},
- Flags: Flags{Help: true},
- },
+ Command: []string{},
+ Flags: Flags{Help: true},
},
"help is noticed when no commands are provided": {
Arguments: []string{"etime", "-p"},
- Command: Command{
- Args: []string{},
- Flags: Flags{Help: true, Portable: true},
- },
+ Command: []string{},
+ Flags: Flags{Help: true, Portable: true},
},
"parsing errors are returned before the command": {
Arguments: []string{"etime", "--help=2"},
- Command: Command{
- Args: []string{},
- Flags: Flags{},
- },
- Error: fmt.Errorf("invalid boolean value"),
+ Command: []string{},
+ Flags: Flags{},
+ Error: fmt.Errorf("invalid boolean value"),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
- command, err := ParseFlags(tt.Arguments)
+ command, flags, err := ParseFlags(tt.Arguments)
if tt.Error != nil {
assert.ErrorContains(t, err, tt.Error.Error())
} else {
assert.NoError(t, err)
assert.Equal(t, tt.Command, command)
+ assert.Equal(t, tt.Flags, flags)
}
})
}
diff --git a/pkg/emporia/cognito.go b/pkg/emporia/cognito.go
deleted file mode 100644
index c5ef4f1..0000000
--- a/pkg/emporia/cognito.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package emporia
-
-import (
- "context"
-
- config "github.com/aws/aws-sdk-go-v2/config"
- cognito "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
-)
-
-// EmporiaCognitoClientID is the AWS Cognito client ID used by Emporia
-var EmporiaCognitoClientID string = "4qte47jbstod8apnfic0bunmrq"
-
-// EmporiaCognitoResponse holds the authentication information from Cognito
-type EmporiaCognitoResponse struct {
- IdToken *string
- RefreshToken *string
- ExpiresIn int32
-}
-
-// GenerateTokens creates new auth tokens from credentials
-func GenerateTokens(credentials EmporiaCredentials) (EmporiaCognitoResponse, error) {
- ctx := context.Background()
- auth := cognito.InitiateAuthInput{
- AuthFlow: "USER_PASSWORD_AUTH",
- AuthParameters: map[string]string{
- "USERNAME": credentials.Username,
- "PASSWORD": credentials.Password,
- },
- ClientId: &EmporiaCognitoClientID,
- }
- if client, err := createCognitoClient(); err != nil {
- return EmporiaCognitoResponse{}, err
- } else if user, err := client.InitiateAuth(ctx, &auth); err != nil {
- return EmporiaCognitoResponse{}, err
- } else {
- return EmporiaCognitoResponse{
- IdToken: user.AuthenticationResult.IdToken,
- RefreshToken: user.AuthenticationResult.RefreshToken,
- ExpiresIn: user.AuthenticationResult.ExpiresIn,
- }, nil
- }
-}
-
-// RefreshTokens regenerates auth tokens from the refresh token
-func RefreshTokens(refreshToken string) (EmporiaCognitoResponse, error) {
- ctx := context.Background()
- auth := cognito.InitiateAuthInput{
- AuthFlow: "REFRESH_TOKEN_AUTH",
- AuthParameters: map[string]string{
- "REFRESH_TOKEN": refreshToken,
- },
- ClientId: &EmporiaCognitoClientID,
- }
- if client, err := createCognitoClient(); err != nil {
- return EmporiaCognitoResponse{}, err
- } else if user, err := client.InitiateAuth(ctx, &auth); err != nil {
- return EmporiaCognitoResponse{}, err
- } else {
- return EmporiaCognitoResponse{
- IdToken: user.AuthenticationResult.IdToken,
- RefreshToken: user.AuthenticationResult.RefreshToken,
- ExpiresIn: user.AuthenticationResult.ExpiresIn,
- }, nil
- }
-}
-
-// createCognitoClient creates a configured identity provider
-func createCognitoClient() (*cognito.Client, error) {
- ctx := context.Background()
- config, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-2"))
- if err != nil {
- return &cognito.Client{}, err
- }
- return cognito.NewFromConfig(config), nil
-}
diff --git a/pkg/emporia/config.go b/pkg/emporia/config.go
deleted file mode 100644
index 171915d..0000000
--- a/pkg/emporia/config.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package emporia
-
-import (
- "encoding/json"
- "errors"
- "log"
- "os"
- "time"
-
- "github.com/zimeg/emporia-time/internal/program"
-)
-
-// EmporiaConfig contains device configurations and user authentications
-type EmporiaConfig struct {
- Device string
- Tokens EmporiaTokenSet
-}
-
-// EmporiaTokenSet contains authentication information needed by Emporia
-type EmporiaTokenSet struct {
- IdToken string
- RefreshToken string
- ExpiresAt time.Time
-}
-
-// SetupConfig prepares the local configurations for a command
-func SetupConfig(flags program.Flags) (EmporiaConfig, error) {
- if config, err := LoadConfigFile(); err != nil {
- return EmporiaConfig{}, err
- } else if err := config.gatherTokens(flags); err != nil {
- return EmporiaConfig{}, err
- } else if err := config.gatherDevice(flags); err != nil {
- return EmporiaConfig{}, err
- } else {
- config.SaveConfig()
- return config, nil
- }
-}
-
-// SetDevice stores the active device in the config
-func (config *EmporiaConfig) SetDevice(device string) {
- config.Device = device
-}
-
-// SetTokens stores newly gathered auth tokens in the config
-func (config *EmporiaConfig) SetTokens(auth EmporiaCognitoResponse) {
- config.Tokens.IdToken = *auth.IdToken
- if auth.RefreshToken != nil {
- config.Tokens.RefreshToken = *auth.RefreshToken
- }
- lifespan := time.Duration(auth.ExpiresIn)
- config.Tokens.ExpiresAt = time.Now().Add(time.Second * lifespan).UTC()
-}
-
-// LoadConfigFile unmarshals stored config data
-func LoadConfigFile() (EmporiaConfig, error) {
- var config EmporiaConfig
- configFilePath := findConfigFilePath()
- if data, err := os.ReadFile(configFilePath); err != nil {
- if errors.Is(err, os.ErrNotExist) {
- return EmporiaConfig{}, nil
- }
- return EmporiaConfig{}, err
- } else if len(data) > 0 {
- if err := json.Unmarshal(data, &config); err != nil {
- return EmporiaConfig{}, err
- }
- return config, nil
- }
- return EmporiaConfig{}, nil
-}
-
-// SaveConfig saves config data to the config file
-func (config *EmporiaConfig) SaveConfig() {
- configFilePath := findConfigFilePath()
- if data, err := json.MarshalIndent(config, "", "\t"); err != nil {
- log.Panicf("Failed to encode config data: %s\n", err)
- } else if err := os.WriteFile(configFilePath, data, 0o660); err != nil {
- log.Fatal(err)
- }
-}
-
-// findConfigFilePath returns the path to stored local credentials
-func findConfigFilePath() string {
- homeDir, err := os.UserHomeDir()
- if err != nil {
- log.Panicf("Failed to find home directory: %s\n", err)
- }
-
- configDir := homeDir + "/.config/etime"
- if val, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok {
- configDir = val + "/etime"
- }
- if err := os.MkdirAll(configDir, 0o755); err != nil {
- log.Panicf("Failed to create config directory: %s\n", err)
- }
-
- configFile := configDir + "/settings.json"
- if file, err := os.OpenFile(configFile, os.O_CREATE, 0o600); err != nil {
- log.Panicf("Failed to open file: %s\n", err)
- } else {
- defer file.Close()
- }
- return configFile
-}
diff --git a/pkg/emporia/request.go b/pkg/emporia/request.go
deleted file mode 100644
index c24f32a..0000000
--- a/pkg/emporia/request.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package emporia
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log"
- "net/http"
- "net/url"
- "time"
-
- "github.com/zimeg/emporia-time/pkg/energy"
- "github.com/zimeg/emporia-time/pkg/times"
-)
-
-const (
- EmporiaAPIRequestURL = "https://api.emporiaenergy.com"
- EmporiaAPIStatusURL = "https://s3.amazonaws.com/com.emporiaenergy.manual.ota/maintenance/maintenance.json"
-)
-
-// Emporia holds information for and from the Emporia API
-type Emporia struct {
- Resp EmporiaUsageResp
- Config EmporiaConfig
-}
-
-// EmporiaUsageResp holds usage information from the response
-type EmporiaUsageResp struct {
- Message string
- FirstUsageInstant string
- UsageList []float64
-}
-
-// EmporiaDeviceResp contains a slice of available devices
-type EmporiaDeviceResp struct {
- Devices []EmporiaDevice
-}
-
-// EmporiaDevice represents a device that can be measured
-type EmporiaDevice struct {
- DeviceGid int
- LocationProperties struct {
- DeviceName string
- }
-}
-
-// CollectEnergyUsage repeatedly calls the Emporia API for usage information
-// until a certain confidence is reached
-func (emp *Emporia) CollectEnergyUsage(times times.TimeMeasurement) (energy.EnergyResult, error) {
- confidence := 0.80
-
- // Delay before lookup to respect latency
- time.Sleep(200 * time.Millisecond)
- chart, err := emp.LookupEnergyUsage(times)
- if err != nil {
- log.Printf("Error: Failed to gather energy usage data!\n")
- return energy.EnergyResult{}, err
- }
-
- results := energy.ExtrapolateUsage(energy.EnergyMeasurement{
- Chart: chart,
- Duration: times.Elapsed,
- })
-
- // Repeat lookup for unsure results
- for results.Sureness < confidence {
- results, err = emp.CollectEnergyUsage(times)
- if err != nil {
- return energy.EnergyResult{}, err
- }
- }
- return results, nil
-}
-
-// LookupEnergyUsage gathers device watt usage between the start and end times
-func (emp *Emporia) LookupEnergyUsage(times times.TimeMeasurement) ([]float64, error) {
- params := emp.formatUsageParams(times)
- chart, err := emp.getEnergyUsage(params)
- if err != nil {
- return []float64{}, err
- }
- for ii, kwh := range chart {
- chart[ii] = energy.ScaleKWhToWs(kwh)
- }
- return chart, nil
-}
-
-// formatUsageParams returns URL values for the API
-//
-// https://github.com/magico13/PyEmVue/blob/master/api_docs.md#getchartusage---usage-over-a-range-of-time
-func (emp *Emporia) formatUsageParams(times times.TimeMeasurement) url.Values {
- params := url.Values{}
- params.Set("apiMethod", "getChartUsage")
- params.Set("deviceGid", emp.Config.Device)
- params.Set("channel", "1,2,3") // ?
- params.Set("start", times.Start.Format(time.RFC3339))
- params.Set("end", times.End.Format(time.RFC3339))
- params.Set("scale", "1S")
- params.Set("energyUnit", "KilowattHours")
- return params
-}
-
-// getEnergyUsage performs a GET request to `/AppAPI` with configured params
-func (emp *Emporia) getEnergyUsage(params url.Values) ([]float64, error) {
- usageURL := fmt.Sprintf("%s/AppAPI?%s", EmporiaAPIRequestURL, params.Encode())
-
- client := &http.Client{}
- req, err := http.NewRequest("GET", usageURL, nil)
- if err != nil {
- return []float64{}, err
- }
- req.Header.Add("authToken", emp.Config.Tokens.IdToken)
-
- resp, err := client.Do(req)
- if err != nil {
- return []float64{}, err
- }
- defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return []float64{}, err
- }
-
- if err := json.Unmarshal(body, &emp.Resp); err != nil {
- return []float64{}, err
- } else if emp.Resp.Message != "" {
- return []float64{}, errors.New(emp.Resp.Message)
- }
- return emp.Resp.UsageList, nil
-}
-
-// getAvailableDevices returns customer devices for the Emporia account
-func getAvailableDevices(token string) ([]EmporiaDevice, error) {
- deviceURL := fmt.Sprintf("%s/customers/devices", EmporiaAPIRequestURL)
-
- client := &http.Client{}
- req, err := http.NewRequest("GET", deviceURL, nil)
- if err != nil {
- return []EmporiaDevice{}, err
- }
- req.Header.Add("authToken", token)
-
- resp, err := client.Do(req)
- if err != nil {
- return []EmporiaDevice{}, err
- }
- defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return []EmporiaDevice{}, err
- }
-
- var devs EmporiaDeviceResp
- err = json.Unmarshal(body, &devs)
- if err != nil {
- return []EmporiaDevice{}, err
- }
-
- return devs.Devices, nil
-}
-
-// EmporiaStatus returns if the Emporia API is available
-//
-// https://github.com/magico13/PyEmVue/blob/master/api_docs.md#detection-of-maintenance
-func EmporiaStatus() (bool, error) {
- resp, err := http.Get(EmporiaAPIStatusURL)
- if err != nil {
- return false, err
- }
- defer resp.Body.Close()
- status := resp.StatusCode == 403
- return status, nil
-}
diff --git a/pkg/emporia/setup.go b/pkg/emporia/setup.go
deleted file mode 100644
index eb2a5bb..0000000
--- a/pkg/emporia/setup.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package emporia
-
-import (
- "errors"
- "flag"
- "fmt"
- "os"
- "strconv"
- "time"
-
- "github.com/zimeg/emporia-time/internal/program"
- "github.com/zimeg/emporia-time/internal/terminal"
-)
-
-// EmporiaCredentials contains basic authentication information
-type EmporiaCredentials struct {
- Username string
- Password string
-}
-
-// headlessLogin returns if all credentials are provided by flag or environment
-func (config *EmporiaConfig) headlessLogin(flags program.Flags) bool {
- return (flags.Username != "" || os.Getenv("EMPORIA_USERNAME") != "") &&
- (flags.Password != "" || os.Getenv("EMPORIA_PASSWORD") != "")
-}
-
-// useCredentials returns if new login credentials should be used
-func (config *EmporiaConfig) useCredentials(flags program.Flags) bool {
- return (config.Tokens.IdToken == "" || config.Tokens.RefreshToken == "") ||
- (flags.Username != "" || os.Getenv("EMPORIA_USERNAME") != "") ||
- (flags.Password != "" || os.Getenv("EMPORIA_PASSWORD") != "")
-}
-
-// gatherTokens collects and sets the tokens needed for calling the Emporia API
-func (config *EmporiaConfig) gatherTokens(flags program.Flags) error {
- if config.useCredentials(flags) {
- if credentials, err := config.gatherCredentials(flags); err != nil {
- return err
- } else if resp, err := GenerateTokens(credentials); err != nil {
- return err
- } else {
- config.SetTokens(resp)
- }
- } else if time.Now().After(config.Tokens.ExpiresAt) {
- if resp, err := RefreshTokens(config.Tokens.RefreshToken); err != nil {
- return err
- } else {
- config.SetTokens(resp)
- }
- }
- return nil
-}
-
-// gatherCredentials prompts for an Emporia username and password
-func (config *EmporiaConfig) gatherCredentials(flags program.Flags) (EmporiaCredentials, error) {
- credentials := EmporiaCredentials{}
- if !config.headlessLogin(flags) {
- fmt.Printf("Enter your Emporia credentials \n")
- }
- if username, err := terminal.CollectInput(&terminal.Prompt{
- Message: "Username",
- Flag: flag.Lookup("username"),
- Environment: "EMPORIA_USERNAME",
- }); err != nil {
- return EmporiaCredentials{}, err
- } else {
- credentials.Username = username
- }
- if password, err := terminal.CollectInput(&terminal.Prompt{
- Message: "Password",
- Flag: flag.Lookup("password"),
- Environment: "EMPORIA_PASSWORD",
- Hidden: true,
- }); err != nil {
- return EmporiaCredentials{}, err
- } else {
- credentials.Password = password
- }
- return credentials, nil
-}
-
-// gatherDevice prompts and stores the choice of an Emporia device
-func (config *EmporiaConfig) gatherDevice(flags program.Flags) error {
- names, gids, gidLabels := []string{}, []string{}, []string{}
- device := ""
-
- devices, err := getAvailableDevices(config.Tokens.IdToken)
- if err != nil {
- return err
- }
- if len(devices) == 0 {
- return errors.New("No available devices found!")
- }
-
- switch {
- case flags.Device != "":
- device = flags.Device
- case os.Getenv("EMPORIA_DEVICE") != "":
- device = os.Getenv("EMPORIA_DEVICE")
- case config.Device != "":
- device = config.Device
- }
-
- for _, val := range devices {
- deviceGid := strconv.Itoa(val.DeviceGid)
- if deviceGid == device || val.LocationProperties.DeviceName == device {
- config.SetDevice(deviceGid)
- return nil
- }
- names = append(names, val.LocationProperties.DeviceName)
- gids = append(gids, deviceGid)
- gidLabels = append(gidLabels, fmt.Sprintf("#%d", val.DeviceGid))
- }
- if device != "" {
- return errors.New("No matching device found!")
- }
-
- if selection, err := terminal.CollectSelect(terminal.Prompt{
- Message: "Select a device:",
- Options: names,
- Descriptions: gidLabels,
- }); err != nil {
- return err
- } else {
- device = gids[selection]
- config.SetDevice(device)
- }
- return nil
-}
diff --git a/pkg/times/time.go b/pkg/times/time.go
index 6069f4d..a731256 100644
--- a/pkg/times/time.go
+++ b/pkg/times/time.go
@@ -8,8 +8,6 @@ import (
"strconv"
"strings"
"time"
-
- "github.com/zimeg/emporia-time/internal/program"
)
// TimeMeasurement holds information of a command run
@@ -43,7 +41,7 @@ func (times TimeMeasurement) GetSys() float64 {
}
// TimeExec performs the command and prints outputs while measuring timing
-func TimeExec(command program.Command) (TimeMeasurement, error) {
+func TimeExec(args []string) (TimeMeasurement, error) {
times := TimeMeasurement{}
stderr := bufferWriter{
buff: &bytes.Buffer{},
@@ -51,7 +49,7 @@ func TimeExec(command program.Command) (TimeMeasurement, error) {
bounds: makeBounds(),
}
- cmd := timerCommand(command.Args, stderr)
+ cmd := timerCommand(args, stderr)
times.Start = time.Now().UTC()
err := cmd.Run()
times.End = time.Now().UTC()