Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dnesting committed Jun 2, 2023
0 parents commit a97fefc
Show file tree
Hide file tree
Showing 30 changed files with 5,147 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.secret
661 changes: 661 additions & 0 deletions LICENSE.txt

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# sense

This package is an incomplete implementation of an entirely UNOFFICIAL
and UNSUPPORTED API to access data for a [Sense](https://sense.com/)
Energy Monitor account. This repository has no affiliation with Sense.

Because this is unsupported, this package may stop working at any time.

The API in this package is not stable and I may change it at any time.

## Usage

```go
import (
"github.com/dnesting/sense"
"github.com/dnesting/sense/realtime"
)

func main() {
ctx := context.Background()
client, err := sense.Connect(ctx, sense.PasswordCredentials{
Email: "you@example.com",
Password: "secret",
})
if err != nil {
log.Fatal(err)
}

fmt.Println("Monitors configured under account", client.AccountID)
for _, m := range client.Monitors {
fmt.Println("-", m.ID)

// Use the realtime Stream API to grab one data point.
fn := func(_ context.Context, msg realtime.Message) error {
if rt, ok := msg.(*realtime.RealtimeUpdate); ok {
fmt.Println(" current power consumption", rt.W, "W")
return realtime.Stop
}
return nil
}
if err := client.Stream(ctx, m.ID, fn); err != nil {
log.Fatal(err)
}
}
}
```

### MFA

If your account requires multi-factor authentication, you can accommodate that like:

```go
mfaFunc := func() (string, error) {
// obtain your MFA code somehow, we'll just use a fixed value to demonstrate
return "12345", nil
}
client, err := sense.Connect(ctx, sense.PasswordCredentials{
Email: "you@example.com",
Password: "secret",
MfaFn: mfaFunc,
})
```

Your `mfaFunc` will be called when needed.

## Notes

This implementation is incomplete, and what's there is incompletely tested.
If you wish to contribute, here's how the project is laid out:

```
|-- internal
| |-- client contains an (incomplete) OpenAPI spec and
| | auto-generated code that does the heavy lifting
| |-- ratelimited implements some HTTP rate limiting
| `-- senseutil helper functions, mocks for testing, etc.
|-- realtime contains a complete-ish AsyncAPI spec but
| hand-generated code implementing the real-time
| WebSockets API
|-- senseauth implements the Sense artisinal OAuth
`-- sensecli helpers that CLI tools might find useful
```

### Debugging

If you need the gory internals to figure something out:

```go
httpClient := sense.SetDebug(log.Default(), nil)
client, err := sense.Connect(ctx, credentials, sense.WithHTTPClient(httpClient))
```
61 changes: 61 additions & 0 deletions cmd/dummy/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// This is a dummy command used to do a basic test that the package is working correctly.
package main

import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"

"github.com/dnesting/sense"
"github.com/dnesting/sense/realtime"
"github.com/dnesting/sense/sensecli"
)

var (
flagDebug = flag.Bool("debug", false, "enable debugging")
// note: other flags set by sensecli.SetupStandardFlags()
)

func main() {
configFile, flagCreds := sensecli.SetupStandardFlags()
flag.Parse()

ctx := context.Background()
httpClient := http.DefaultClient
if *flagDebug {
// enable HTTP client logging
httpClient = sense.SetDebug(log.New(os.Stderr, "", log.LstdFlags|log.Lshortfile), httpClient)
}

clients, err := sensecli.CreateClients(ctx,
configFile, flagCreds,
sense.WithHttpClient(httpClient))
if err != nil {
log.Fatal(err)
}

for _, client := range clients {
fmt.Println("account:", client.AccountID)
fmt.Println("monitors:")
for _, monitor := range client.Monitors {
fmt.Println("- monitor: ", monitor.ID, monitor.SerialNumber)
var count = 3
fn := func(_ context.Context, msg realtime.Message) error {
if rt, ok := msg.(*realtime.RealtimeUpdate); ok {
fmt.Printf(" W: %.1f\n", rt.W)
count--
if count == 0 {
return realtime.Stop
}
}
return nil
}
if err := client.Stream(ctx, monitor.ID, fn); err != nil {
log.Println(err)
}
}
}
}
55 changes: 55 additions & 0 deletions debug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package sense

import (
"fmt"
"log"
"net/http"
"strings"

"github.com/dnesting/sense/internal/senseutil"
"github.com/dnesting/sense/realtime"
"github.com/dnesting/sense/senseauth"
)

var debugLogger *log.Logger

type loggingTransport struct {
transport http.RoundTripper
}

func debug(args ...interface{}) {
if debugLogger != nil {
debugLogger.Output(2, strings.TrimRight(fmt.Sprint(args...), "\n"))
}
}

func (s *loggingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
senseutil.DumpRequest(debugLogger, r)

tr := s.transport
if tr == nil {
tr = http.DefaultTransport
}
resp, err := tr.RoundTrip(r)
if err != nil {
debug("error:", err)
return nil, err
}
senseutil.DumpResponse(debugLogger, resp)
return resp, err
}

// SetDebug enables debug logging using the given logger and returns an
// [*http.Client] that wraps baseClient, logging requests and responses
// to the same logger. Passing nil will disable debug logging.
func SetDebug(l *log.Logger, baseClient *http.Client) *http.Client {
if baseClient == nil {
baseClient = http.DefaultClient
}
debugLogger = l
senseauth.SetDebug(l)
realtime.SetDebug(l)
return &http.Client{
Transport: &loggingTransport{baseClient.Transport},
}
}
60 changes: 60 additions & 0 deletions device.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package sense

import (
"context"

"github.com/dnesting/sense/internal/client"
)

type Device struct {
ID string
Name string
Type string
Make string
}

// I'm not entirely sure what the relationship between these fields is, so
// just pick one that seems reasonable.
func getType(d client.Device) string {
tags := deref(d.Tags)

for _, tag := range []string{
"UserDeviceType",
"Type",
"DefaultUserDeviceType",
} {
if s := stringOrEmpty(tags[tag]); s != "" {
return s
}
}
return ""
}

func stringOrEmpty(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}

// GetDevices returns a list of devices known to the given monitor.
func (s *Client) GetDevices(ctx context.Context, monitorID int, includeMerged bool) (devs []Device, err error) {
res, err1 := s.client.GetDevicesWithResponse(
ctx,
monitorID,
&client.GetDevicesParams{
IncludeMerged: &includeMerged,
})
if err := client.Ensure(err1, "GetDevices", res, 200); err != nil {
return nil, err
}
for _, d := range deref(res.JSON200.Devices) {
devs = append(devs, Device{
ID: deref(d.Id),
Name: deref(d.Name),
Type: getType(d),
Make: stringOrEmpty(deref(d.Tags)["Make"]),
})
}
return devs, nil
}
43 changes: 43 additions & 0 deletions environments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package sense

/*
// This is constructed like any other exported API but for now we'll just make
// it accessible through WithEnvironment() until there's a clear need for it.
// Environment is a Sense environment. This type might be useful if you worked
// for Sense and wanted to use another environment.
type environment struct {
ID string
Name string
ApiURL string
}
type environments []environment
// Get returns the environment with the given ID, or nil if not found.
func (e environments) Get(id string) *environment {
// a map is overkill since there are few items and we will do this search rarely.
for _, env := range e {
if env.ID == id {
return &env
}
}
return nil
}
// getEnvironments returns a list of environments from the Sense API.
func (s *Client) getEnvironments(ctx context.Context) (envs environments, err error) {
res, err1 := s.client.GetEnvironmentsWithResponse(ctx)
if err := client.Ensure(err1, "getEnvironments", res, 200); err != nil {
return nil, err
}
for _, e := range *res.JSON200 {
envs = append(envs, environment{
ID: deref(e.Environment),
Name: deref(e.DisplayName),
ApiURL: deref(e.ApiUrl),
})
}
return envs, nil
}
*/
26 changes: 26 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module github.com/dnesting/sense

go 1.20

require (
github.com/deepmap/oapi-codegen v1.12.4
golang.org/x/oauth2 v0.8.0
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/klauspost/compress v1.10.3 // indirect
)

require (
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.0
github.com/gorilla/websocket v1.5.0 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/time v0.3.0
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
nhooyr.io/websocket v1.8.7
)
Loading

0 comments on commit a97fefc

Please sign in to comment.