# Build Your Own Client

Build a non-Home-Assistant client against the Hungry Machines API — Python, mobile app, ESP32, or any platform that can speak HTTPS.

This is the **most advanced path**. Take it when Home Assistant isn't an option — you're integrating Hungry Machines into a mobile app, an ESP32 firmware, a Python script on a Pi, or any other platform. You'll be responsible for the full integration loop: authentication, token refresh, sensor collection, schedule application, and any local device control.

If you have Home Assistant, the [Energy Dashboard](/home-assistant/) is much faster. If you want to extend Home Assistant with custom cards or REST sensors, see [Custom Home Assistant integration](/guides/custom-home-assistant/).

## Integration overview

A Hungry Machines client does two things:

1. **Push readings** — send sensor data every 5 minutes so the optimizer can learn your home's thermal behavior
2. **Pull schedules** — fetch optimized setpoints once daily and apply them locally

Your client handles all local device control (hysteresis, safety limits, hardware communication). The API handles optimization math.

> **Note**
>
> All authentication happens through `api.hungrymachines.io` — use your Hungry Machines email and password, no separate credentials required. See the [Authentication reference](/getting-started/authentication/) for the full lifecycle.

## Initial setup

### 1. Create your account

Sign up at **[hungrymachines.io/signup](https://hungrymachines.io/signup)** and confirm your email. Always sign up through the website — programmatic signup skips billing and onboarding setup.

### 2. Get an access token

Once your account is active, exchange credentials for a session:

```bash
curl -X POST https://api.hungrymachines.io/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "your-password"}'
```

Save `access_token` and `refresh_token` from the response. Access tokens expire after 1 hour — use the refresh token to get a fresh one without re-entering credentials.

### 3. Set preferences

```bash
curl -X PUT https://api.hungrymachines.io/api/v1/preferences \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "base_temperature": 72.0,
    "savings_level": 2,
    "time_away": "08:00",
    "time_home": "17:00",
    "optimization_mode": "cool"
  }'
```

### 4. Register appliances

Every non-solar appliance config requires an `entity_id` — the Home Assistant entity (or analogous identifier in your platform) the integration will read sensors from and apply setpoints/toggles to. See [API Reference — Appliances](/api-reference/appliances/) for the full per-type config schema.

```bash
curl -X POST https://api.hungrymachines.io/api/v1/appliances \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "appliance_type": "hvac",
    "name": "Main Floor AC",
    "config": {
      "hvac_type": "central_ac",
      "home_size_sqft": 2000,
      "entity_id": "climate.main_floor"
    }
  }'
```

### 5. Set constraints (non-HVAC appliances)

For EV chargers and batteries, set optimization constraints:

```bash
curl -X POST https://api.hungrymachines.io/api/v1/appliances/$APPLIANCE_ID/constraints \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "target_charge_pct": 80,
    "min_charge_pct": 30,
    "deadline_time": "07:00",
    "current_charge_pct": 35
  }'
```

## Daily loop

Once set up, your client runs two recurring tasks:

### Every 5 minutes: push readings

Collect sensor data and POST it to the API. Include at minimum `timestamp`, `indoor_temp`, and `hvac_state`.

### After the overnight run, and whenever it changes: pull schedule

After the overnight optimization has run, fetch the day's schedule and cache it locally. Apply the corresponding setpoints every 30 minutes.

A plan for today can be replaced during the day, after a settings change and when the API re-plans the rest of the day. Poll [`GET /api/v1/schedules/updated-at`](/api-reference/appliances/#get-apiv1schedulesupdated-at) every few minutes, keep the last value you saw, and fetch the schedule again when it changes.

## The 48-interval schedule

Every schedule contains **48 intervals** of 30 minutes each, covering a full 24-hour day:

| Interval | Time window |
|---|---|
| 0 | 00:00–00:30 |
| 1 | 00:30–01:00 |
| 16 | 08:00–08:30 |
| 34 | 17:00–17:30 |
| 47 | 23:30–00:00 |

To find the current interval: `interval = (hour * 2) + (1 if minute >= 30 else 0)`.

All timestamps in the API are **UTC**. Convert to local time before applying setpoints.

## Handling `source: "defaults"`

`source` is `"defaults"` only until the first plan is stored for the appliance. That happens on the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run, whichever comes first. What "defaults" looks like depends on the route:

- **`GET /api/v1/schedule`** (one HVAC unit, used in the examples below) returns a comfort band built from your preferences in `high_temps` / `low_temps`, with `setpoint_temps` and `temp_trajectory` set to `null`. Apply the midpoint of the band instead: `(high_temps[idx] + low_temps[idx]) / 2`. Every key is always present on this route.
- **`GET /api/v1/schedules`** (all appliances) returns `"schedule": {}` for that appliance. There is no band to fall back to, so send it no commands. On this route a schedule key is present or absent, never `null`, so read keys defensively — `s.get("setpoint_temps")` in Python, `s.setpoint_temps ?? null` in JavaScript.

From the first stored plan onward, `source` is `"optimization"` and `setpoint_temps` is a 48-element array every day. `estimated_savings_pct` can still read `0.0` on early plans, for example before any readings have arrived.

## Example: Python client (httpx)

```python
import httpx
import time
from datetime import datetime, timezone

API = "https://api.hungrymachines.io"

class HMClient:
    def __init__(self, email: str, password: str):
        resp = httpx.post(f"{API}/auth/login", json={"email": email, "password": password})
        resp.raise_for_status()
        s = resp.json()
        self.access_token = s["access_token"]
        self.refresh_token = s["refresh_token"]
        self.expires_at = s["expires_at"]

    def _refresh(self):
        resp = httpx.post(f"{API}/auth/refresh", json={"refresh_token": self.refresh_token})
        resp.raise_for_status()
        s = resp.json()
        self.access_token = s["access_token"]
        self.refresh_token = s["refresh_token"]
        self.expires_at = s["expires_at"]

    def _headers(self) -> dict:
        # Refresh proactively with 5 minutes of headroom.
        if int(datetime.now(timezone.utc).timestamp()) >= self.expires_at - 300:
            self._refresh()
        return {"Authorization": f"Bearer {self.access_token}"}

    def push_reading(self, indoor_temp: float, hvac_state: str, outdoor_temp: float):
        return httpx.post(f"{API}/api/v1/readings", headers=self._headers(), json={
            "readings": [{
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "indoor_temp": indoor_temp,
                "hvac_state": hvac_state,
                "outdoor_temp": outdoor_temp,
            }],
        }).raise_for_status()

    def get_schedule(self):
        resp = httpx.get(f"{API}/api/v1/schedule", headers=self._headers())
        resp.raise_for_status()
        return resp.json()


def current_interval() -> int:
    now = datetime.now()
    return now.hour * 2 + (1 if now.minute >= 30 else 0)


def apply_setpoints(schedule: dict):
    idx = current_interval()
    s = schedule["schedule"]
    setpoints = s.get("setpoint_temps")
    if setpoints is not None:
        target = setpoints[idx]
    else:
        # Defaults path (no stored plan yet) — fall back to the band midpoint.
        target = (s["high_temps"][idx] + s["low_temps"][idx]) / 2
    print(f"Interval {idx}: thermostat target {target}F")
    # TODO: send to your thermostat hardware (e.g. climate.set_temperature)


# Main loop
client = HMClient("you@example.com", "secure-password")
schedule = client.get_schedule()
while True:
    indoor = read_sensor()          # your hardware function
    outdoor = read_outdoor_temp()   # your hardware function
    client.push_reading(indoor, "COOL", outdoor)
    apply_setpoints(schedule)
    time.sleep(300)
```

Persist `access_token`, `refresh_token`, and `expires_at` to disk so the client survives restarts without asking for credentials again.

## Example: ESP32 (pseudo-code)

For microcontroller integrations, the pattern is the same — just adapted for constrained environments:

```cpp
// ESP32 Arduino pseudo-code

#include <HTTPClient.h>
#include <ArduinoJson.h>

const char* API = "https://api.hungrymachines.io";

String accessToken;   // load from NVS on boot
String refreshToken;  // load from NVS on boot

bool refreshAccessToken() {
    HTTPClient http;
    http.begin(String(API) + "/auth/refresh");
    http.addHeader("Content-Type", "application/json");
    String body = "{\"refresh_token\":\"" + refreshToken + "\"}";
    int code = http.POST(body);
    if (code == 200) {
        StaticJsonDocument<1024> doc;
        deserializeJson(doc, http.getString());
        accessToken = doc["access_token"].as<String>();
        refreshToken = doc["refresh_token"].as<String>();
        saveToNVS();  // persist for reboots
        http.end();
        return true;
    }
    http.end();
    return false;
}

void pushReading(float indoorTemp, const char* hvacState) {
    HTTPClient http;
    http.begin(String(API) + "/api/v1/readings");
    http.addHeader("Authorization", "Bearer " + accessToken);
    http.addHeader("Content-Type", "application/json");

    StaticJsonDocument<256> doc;
    JsonArray readings = doc.createNestedArray("readings");
    JsonObject r = readings.createNestedObject();
    r["timestamp"] = getISOTimestamp();  // UTC ISO 8601
    r["indoor_temp"] = indoorTemp;
    r["hvac_state"] = hvacState;

    String body;
    serializeJson(doc, body);
    int code = http.POST(body);

    if (code == 401 && refreshAccessToken()) {
        http.end();
        pushReading(indoorTemp, hvacState);  // retry once
        return;
    }
    http.end();
}

float setpointTemps[48];
bool setpointTempsValid = false;
float highTemps[48];
float lowTemps[48];

void fetchSchedule() {
    HTTPClient http;
    http.begin(String(API) + "/api/v1/schedule");
    http.addHeader("Authorization", "Bearer " + accessToken);
    int code = http.GET();

    if (code == 200) {
        // Parse schedule.setpoint_temps (preferred) into setpointTemps[];
        // mark setpointTempsValid = false when the field is null (defaults
        // path) and fall back to high_temps / low_temps for display.
        // Always populate highTemps[] and lowTemps[] for UI rendering.
    }
    http.end();
}

void loop() {
    float temp = readSensor();
    pushReading(temp, "COOL");
    int interval = (hour() * 2) + (minute() >= 30 ? 1 : 0);
    float target;
    if (setpointTempsValid) {
        target = setpointTemps[interval];
    } else {
        target = (highTemps[interval] + lowTemps[interval]) / 2.0f;
    }
    applySetpoint(target);
    delay(300000);  // 5 minutes
}
```

> **Tip**
>
> Store both `access_token` and `refresh_token` in NVS (non-volatile storage) so they survive reboots. Refresh proactively (e.g. every 50 minutes) rather than waiting for a 401.

## Key points

- **Sign up at hungrymachines.io** — programmatic signup via `/auth/signup` skips billing setup; always create accounts through the website.
- **Two auth endpoints for tokens** — `/auth/login` and `/auth/refresh`. Both live on `api.hungrymachines.io`, both use email + password.
- **1-hour access token, 30-day refresh token** — the refresh token is rotated on every `/auth/refresh` call, so save the new one each time.
- **UTC timestamps** — all API timestamps are UTC. Convert to local time for display and setpoint timing.
- **Apply `setpoint_temps[idx]`, not `high_temps`/`low_temps`** — the optimizer emits a per-interval target temperature directly. The high/low arrays are display-only comfort bounds.
- **48 intervals** — every schedule is 48 half-hour slots. `interval = hour * 2 + (minute >= 30)`.
- **Handle `source: "defaults"`** — it lasts only until the first plan is stored. On `GET /api/v1/schedule`, `setpoint_temps` is `null` and you apply the band midpoint. On `GET /api/v1/schedules` the appliance's `schedule` is `{}` and there is nothing to apply.
- **Poll `GET /api/v1/schedules/updated-at`** — plans are replaced during the day; fetch the schedule again when the value changes.
- **Omit the `client` block on readings** — its `kind` only accepts the two first-party clients.
- **Local hysteresis** — your client handles thermostat hysteresis and safety limits. The API provides target setpoints, not direct control signals.
- **Rate limit** — 600 readings per active HVAC or dehumidifier appliance, per account, per UTC day. Normal 5-minute reporting (288 readings a day per unit) is well within it; it only trips on runaway loops or large catch-up batches.

## Next steps

- [Authentication](/getting-started/authentication/) — full token lifecycle reference
- [API Reference — Readings](/api-reference/readings/) — exact request/response schemas
- [API Reference — Schedule](/api-reference/schedule/) — 48-interval schedule shape
- [API Reference — Appliances](/api-reference/appliances/) — multi-device registration + constraints
- [Custom Home Assistant integration](/guides/custom-home-assistant/) — same API from inside Home Assistant if you change your mind
- [OpenAPI description](/api-reference/openapi/) — every route in this guide as one machine-readable file, for generating a typed client
- [Reference Clients](/guides/reference-clients/) — copy-ready, dependency-free Python and JavaScript files covering the same loop

Canonical page: https://docs.hungrymachines.io/guides/build-your-own-client/
