Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ chmod 600 ~/.config/eightctl/config.yaml
# check pod state
EIGHTCTL_EMAIL=you@example.com EIGHTCTL_PASSWORD=your-password eightctl status

# set temperature level (-100..100)
# set temperature level (-100..100); without --side, applies to all discovered sides/users
eightctl temp 20

# target a specific side when the household is split
eightctl temp -40 --side right
eightctl on --side left

# run daemon with your YAML schedule (see docs/example-schedule.yaml)
eightctl daemon --dry-run
```
Expand All @@ -40,14 +44,21 @@ eightctl daemon --dry-run
- **Audio:** `audio tracks|categories|state|play|pause|seek|volume|pair|next`, `audio favorites list|add|remove`
- **Base:** `base info|angle|presets|preset-run|vibration-test`
- **Device:** `device info|peripherals|owner|warranty|online|priming-tasks|priming-schedule`
- **Metrics & insights:** `sleep day|range`, `presence`, `metrics trends|intervals|insights`
- **Metrics & insights:** `sleep day|range`, `presence [--from --to]`, `metrics trends|intervals|insights`
- **Autopilot:** `autopilot details|history|recap`, `autopilot set-level-suggestions`, `autopilot set-snore-mitigation`
- **Travel:** `travel trips|create-trip|delete-trip|plans|create-plan|update-plan|tasks|airport-search|flight-status`
- **Household:** `household summary|schedule|current-set|invitations|devices|users|guests`
- **Misc:** `tracks`, `feats`, `whoami`, `version`

Use `--output table|json|csv` and `--fields field1,field2` to shape output. `--verbose` enables debug logs; `--quiet` hides the config banner.

## Household Targeting
- `status` shows discovered household targets by default when available, including `left` / `right` or inferred `solo`.
- `on`, `off`, and `temp` apply to all discovered household targets by default.
- Use `--side left|right|solo` to target one household side.
- Use `--target-user-id <id>` when you want to address a specific discovered user directly.
- For split households, `eightctl status --output json` is the quickest way to inspect available sides and user IDs.

## Configuration
Priority: flags > env vars (`EIGHTCTL_*`) > config file.

Expand Down
9 changes: 6 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Metrics & insights:
- `metrics aggregate`
- `metrics insights`
- `sleep day --date`, `sleep range --from --to`
- `presence`
- `presence [--from --to]`

Autopilot:
- `autopilot details|history|recap`
Expand All @@ -63,22 +63,25 @@ Travel:
- `travel flight-status --flight`

Household:
- `household summary|schedule|current-set|invitations`
- `household summary|schedule|current-set|invitations|devices|users|guests`

Audio/temperature data helpers:
- `tracks`, `feats` remain for backward compatibility.

## Output & UX
- Output formats: table (default), json, csv via `--output`; `--fields` to select columns.
- Logs via charmbracelet/log; `--verbose` for debug; `--quiet` hides config notice.
- `status` should prefer discovered household targets when available and display `left` / `right` or inferred `solo`.
- `on`, `off`, and `temp` should default to all discovered household targets unless narrowed with `--side` or `--target-user-id`.
- `temp` accepts negative positional levels such as `temp -40` without requiring `--`.

## Daemon Behavior
- Reads YAML schedule (time, action on|off|temp, temperature with unit), minute tick, executes once per day, PID guard, SIGINT/SIGTERM graceful stop.
- Optional state sync compares expected schedule state vs device and reconciles.

## Testing & Quality Gates
- `go test ./...` (fast compile checks) — run before handoff.
- Formatting via `gofmt`; prefer `gofumpt`/`staticcheck` later.
- Formatting via `gofumpt`; prefer `staticcheck`/additional linting later as needed.
- Live checks: `eightctl status`, `metrics summary`, `tempmode nap status` with test creds to validate auth + userId resolution.

## Prior Work (references)
Expand Down
102 changes: 78 additions & 24 deletions internal/client/eightsleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

const (
defaultBaseURL = "https://client-api.8slp.net/v1"
defaultAppURL = "https://app-api.8slp.net"
// Extracted from the official Eight Sleep Android app v7.39.17 (public client creds)
defaultClientID = "0894c7f33bb94800a03f1f4df13a4f38"
defaultClientSecret = "f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76"
Expand All @@ -40,12 +41,12 @@ type Client struct {

HTTP *http.Client
BaseURL string
AppURL string
token string
tokenExp time.Time
}

// New creates a Client.

func New(email, password, userID, clientID, clientSecret string) *Client {
if clientID == "" {
clientID = defaultClientID
Expand All @@ -67,6 +68,7 @@ func New(email, password, userID, clientID, clientSecret string) *Client {
ClientSecret: clientSecret,
HTTP: &http.Client{Timeout: 20 * time.Second, Transport: tr},
BaseURL: defaultBaseURL,
AppURL: defaultAppURL,
}
}

Expand Down Expand Up @@ -102,6 +104,7 @@ func (c *Client) EnsureDeviceID(ctx context.Context) (string, error) {
}
var res struct {
User struct {
Devices []string `json:"devices"`
CurrentDevice struct {
ID string `json:"id"`
} `json:"currentDevice"`
Expand All @@ -110,10 +113,14 @@ func (c *Client) EnsureDeviceID(ctx context.Context) (string, error) {
if err := c.do(ctx, http.MethodGet, "/users/me", nil, nil, &res); err != nil {
return "", err
}
if res.User.CurrentDevice.ID == "" {
if res.User.CurrentDevice.ID != "" {
c.DeviceID = res.User.CurrentDevice.ID
return c.DeviceID, nil
}
if len(res.User.Devices) == 0 {
return "", errors.New("no current device id")
}
c.DeviceID = res.User.CurrentDevice.ID
c.DeviceID = res.User.Devices[0]
return c.DeviceID, nil
}

Expand Down Expand Up @@ -210,6 +217,14 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
return c.doURL(ctx, method, u, body, out)
}

func (c *Client) doApp(ctx context.Context, method, path string, query url.Values, body any, out any) error {
u := c.AppURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
return c.doURL(ctx, method, u, body, out)
}

// doURL sends an authenticated request to an absolute URL. Use do() for
// BaseURL-relative paths; use doURL directly for requests to other hosts
// (e.g. the app API for away mode).
Expand Down Expand Up @@ -238,9 +253,7 @@ func (c *Client) doURLRetry(ctx context.Context, method, u string, body any, out
req.Header.Set("Accept", "application/json")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("User-Agent", "okhttp/4.9.3")
// Note: we intentionally do NOT set Accept-Encoding. Go's http.Transport
// handles gzip transparently when the header is absent. Setting it
// explicitly disables automatic decompression.
// Do not set Accept-Encoding explicitly; Go handles gzip transparently.

resp, err := c.HTTP.Do(req)
if err != nil {
Expand Down Expand Up @@ -277,21 +290,37 @@ func (c *Client) doURLRetry(ctx context.Context, method, u string, body any, out

// TurnOn powers device on.
func (c *Client) TurnOn(ctx context.Context) error {
return c.setPower(ctx, true)
return c.TurnOnForUser(ctx, "")
}

// TurnOff powers device off.
func (c *Client) TurnOff(ctx context.Context) error {
return c.setPower(ctx, false)
return c.TurnOffForUser(ctx, "")
}

func (c *Client) setPower(ctx context.Context, on bool) error {
if err := c.requireUser(ctx); err != nil {
return err
func (c *Client) TurnOnForUser(ctx context.Context, userID string) error {
return c.setPowerForUser(ctx, userID, true)
}

func (c *Client) TurnOffForUser(ctx context.Context, userID string) error {
return c.setPowerForUser(ctx, userID, false)
}

func (c *Client) setPowerForUser(ctx context.Context, userID string, on bool) error {
targetUserID := userID
if targetUserID == "" {
if err := c.requireUser(ctx); err != nil {
return err
}
targetUserID = c.UserID
}
path := fmt.Sprintf("/v1/users/%s/temperature", targetUserID)
state := "off"
if on {
state = "smart"
}
path := fmt.Sprintf("/users/%s/devices/power", c.UserID)
body := map[string]bool{"on": on}
return c.do(ctx, http.MethodPost, path, nil, body, nil)
body := map[string]any{"currentState": map[string]string{"type": state}}
return c.doApp(ctx, http.MethodPut, path, nil, body, nil)
}

func (c *Client) Identity() tokencache.Identity {
Expand All @@ -302,17 +331,34 @@ func (c *Client) Identity() tokencache.Identity {
}
}

// SetTemperature sets target heating/cooling level (-100..100).
// SetTemperature sets target heating/cooling level (-100..100) for the
// authenticated user's current pod side.
func (c *Client) SetTemperature(ctx context.Context, level int) error {
if err := c.requireUser(ctx); err != nil {
return err
}
return c.SetTemperatureForUser(ctx, "", level)
}

// SetTemperatureForUser sets target heating/cooling level (-100..100) for a
// specific household user ID. If userID is empty, the authenticated user's ID
// is resolved and used.
func (c *Client) SetTemperatureForUser(ctx context.Context, userID string, level int) error {
if level < -100 || level > 100 {
return fmt.Errorf("level must be between -100 and 100")
}
path := fmt.Sprintf("/users/%s/temperature", c.UserID)
targetUserID := userID
if targetUserID == "" {
if err := c.requireUser(ctx); err != nil {
return err
}
targetUserID = c.UserID
}
path := fmt.Sprintf("/v1/users/%s/temperature", targetUserID)
if err := c.doApp(ctx, http.MethodPut, path, nil, map[string]any{
"currentState": map[string]string{"type": "smart"},
}, nil); err != nil {
return err
}
body := map[string]int{"currentLevel": level}
return c.do(ctx, http.MethodPut, path, nil, body, nil)
return c.doApp(ctx, http.MethodPut, path, nil, body, nil)
}

// SetAwayMode activates or deactivates away mode for a specific user ID.
Expand Down Expand Up @@ -347,12 +393,20 @@ type TempStatus struct {

// GetStatus fetches temperature-based status (current mode/level).
func (c *Client) GetStatus(ctx context.Context) (*TempStatus, error) {
if err := c.requireUser(ctx); err != nil {
return nil, err
return c.GetStatusForUser(ctx, "")
}

func (c *Client) GetStatusForUser(ctx context.Context, userID string) (*TempStatus, error) {
targetUserID := userID
if targetUserID == "" {
if err := c.requireUser(ctx); err != nil {
return nil, err
}
targetUserID = c.UserID
}
path := fmt.Sprintf("/users/%s/temperature", c.UserID)
path := fmt.Sprintf("/v1/users/%s/temperature", targetUserID)
var res TempStatus
if err := c.do(ctx, http.MethodGet, path, nil, nil, &res); err != nil {
if err := c.doApp(ctx, http.MethodGet, path, nil, nil, &res); err != nil {
return nil, err
}
return &res, nil
Expand Down
Loading