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()