# Readings

Push HVAC sensor data to Hungry Machines via POST /api/v1/readings.

The readings endpoint accepts HVAC sensor data from your thermostat or Home Assistant integration. Push a reading every 5 minutes (or batch several) so Hungry Machines can learn how your home responds and produce accurate schedules.

## POST /api/v1/readings

**Auth:** Required — a user access token.

### Required vs. optional

Only **three** fields are required on each reading: `timestamp`, `indoor_temp`, and `hvac_state`. Everything else is **optional** — but the more you send, the more accurate your schedules become. Send whatever your device exposes; there's no penalty for omitting a field you don't have.

> **Tip**
>
> There's one exception worth calling out: once an account has **more than one** reading-producing appliance (a second HVAC, or a dehumidifier), tag every reading with `appliance_id`. The API accepts a reading without it, but it can't attribute that reading to one unit. A single-HVAC account can omit it.

### Request body

```json
{
  "readings": [
    {
      "timestamp": "2025-11-18T14:30:00+00:00",
      "indoor_temp": 72.5,
      "hvac_state": "COOL",
      "indoor_humidity": 45.0,
      "outdoor_temp": 85.0,
      "target_temp": 72.0,
      "fan_mode": "auto",
      "power_watts": 298.0,
      "hvac_action": "cooling",
      "commanded_hvac_mode": "COOL",
      "commanded_fan_mode": "low",
      "commanded_setpoint": 71.0,
      "indoor_source": "sensor",
      "override_active": false,
      "room_target": 73.0,
      "appliance_id": "11111111-1111-1111-1111-111111111111"
    }
  ],
  "client": {
    "kind": "hacs",
    "version": "3.7.0",
    "node_bundle": null,
    "ha_version": "2026.8.1"
  }
}
```

### Fields

| Field | Type | Required | Constraints / notes |
|---|---|---|---|
| `readings` | array | **Yes** | 1–100 reading objects (below) |
| `client` | object | No | Identifies the client software. **A third-party client should send `kind: "custom"`**, optionally with `name` — see [Client identity](#client-identity). |

#### Per-reading fields

| Field | Type | Required | Constraints / notes |
|---|---|---|---|
| `timestamp` | string (ISO 8601) | **Yes** | UTC recommended |
| `indoor_temp` | float | **Yes** | 30.0–120.0 °F |
| `hvac_state` | string | **Yes** | `"HEAT"`, `"COOL"`, `"ECO"`, `"DRY"`, `"OFF"`, or `"FAN"`. Case-insensitive. Common aliases (`"cooling"`, `"heating"`, `"eco_cool"`, `"dehumidify"`, `"fan_only"`, `"idle"`) are normalized server-side. `"auto"` and `"heat_cool"` say what the unit may do, not what it is doing, and are recorded as `"OFF"`. Any other value is rejected with `422`. `"ECO"` and `"DRY"` are tracked separately from `"COOL"`. |
| `indoor_humidity` | float | No | Percent (0–100) |
| `outdoor_temp` | float | No | Fahrenheit |
| `target_temp` | float | No | Current thermostat setpoint |
| `fan_mode` | string | No | Fan-speed label, e.g. `"auto"`, `"low"`, `"high"`. Stored as sent. |
| `power_watts` | float | No | Current power draw. **Send only if the meter measures this specific unit** — a shared/whole-home meter can mislead the model. |
| `hvac_action` | string | No | The climate entity's `hvac_action` attribute (`"cooling"`, `"heating"`, `"idle"`, `"off"`, …) — whether the unit is *actively* conditioning right now, as opposed to its standing mode. Send when available; improves accuracy. |
| `commanded_hvac_mode` | string | No | The mode your integration last commanded (`"COOL"`/`"HEAT"`/`"OFF"`). Send when available; improves accuracy. |
| `commanded_fan_mode` | string | No | The fan label your integration last commanded. |
| `commanded_setpoint` | float | No | The setpoint (°F) your integration last commanded. |
| `indoor_source` | string | No | Which source produced `indoor_temp`: `"sensor"` if you read a separate room-temperature sensor, `"entity"` if you used the thermostat's own reading. Any other value is rejected with `422`. Send it when you know. |
| `override_active` | boolean | No | `true` if your client's own local override, rather than the schedule, was commanding the unit when you took the reading. Leave it out if your client has no such override: omitted means "not reported", which is recorded differently from `false`. |
| `room_target` | float | No | The room temperature the schedule asked for in that slot (`schedule.room_target_temps[slot]`, returned by [`GET /api/v1/schedules`](/api-reference/appliances/#get-apiv1schedules)), as opposed to `target_temp`, the setpoint the unit was carrying. Send it only if your client steers the unit toward `room_target_temps`; otherwise leave it out. |
| `appliance_id` | string (UUID) | No | Tags the reading with a specific HVAC (or dehumidifier) unit. A reading without it is accepted but can't be attributed to one unit, so **send it on every reading once the account has more than one reading-producing appliance**. When present it must be an HVAC or dehumidifier appliance the caller owns, else `400`. |

### Client identity

The batch-level `client` object names the client software. It never affects the schedules you get back, and leaving the whole block out changes nothing else.

| Field | Type | Required | Constraints / notes |
|---|---|---|---|
| `kind` | string | **Yes** (within `client`) | `"hacs"` for a self-install integration, `"node"` for a managed fleet node, `"custom"` for any third-party client. Anything else is rejected with `422`. |
| `version` | string | **Yes** (within `client`) | Your client's version string, 1–40 characters. |
| `node_bundle` | string | No | The node bundle tag, if you run one. `null` otherwise. |
| `ha_version` | string | No | Your Home Assistant version, if you have one. |
| `name` | string | No | A label for your client, 1–40 characters (letters, digits, space, `.`, `_`, `-`). Send it with `kind: "custom"` to say what your client is. |

**Building your own client? Send `{"kind": "custom", "name": "<your client>"}`.** Before this field existed, a third-party client had no honest value for `kind` and was told to omit the block entirely; `"custom"` is now that honest value.

### Example (minimum viable reading)

```bash
curl -X POST https://api.hungrymachines.io/api/v1/readings \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "readings": [
      {
        "timestamp": "2025-11-18T14:30:00+00:00",
        "indoor_temp": 72.5,
        "hvac_state": "COOL"
      }
    ]
  }'
```

### Response (201)

```json
{
  "accepted": 1
}
```

The `accepted` count reflects how many readings were stored.

### Errors

| Status | Detail | Cause |
|---|---|---|
| 400 | `"appliance_id <uuid> is not a hvac or dehumidifier appliance owned by this user"` | `appliance_id` doesn't reference an HVAC or dehumidifier unit the caller owns |
| 401 | `"Not authenticated"` | No `Authorization` header |
| 401 | `"Invalid credentials"` | The token is not valid. An expired token gets this string on this route, so refresh it and retry. |
| 422 | Array `detail` (see [Errors & limits](/api-reference/errors-and-limits/)) | A field failed validation: `indoor_temp` outside 30–120, an unrecognised `hvac_state`, zero or more than 100 readings, a non-string label, an `indoor_source` other than `"sensor"` / `"entity"`, a `client.kind` that isn't `"hacs"`, `"node"` or `"custom"`, a `client.version` that is empty or over 40 characters, or a `client.name` that's empty, over 40 characters, or has a character other than letters/digits/space/`.`/`_`/`-`. The whole batch is rejected. |
| 429 | `"Rate limit exceeded: max <cap> readings per user per day (scaled to <N> active reading-producing appliance(s))"` | Over the daily reading ceiling (see below) |
| 503 | `"Database unavailable"` | Temporary; retry |

### Rate limit

The ceiling is **600 readings per active reading-producing appliance (HVAC or dehumidifier), per account, per UTC day**. An account with no such appliance registered yet gets 600. One unit reporting every 5 minutes sends 288 a day, so the limit only trips on a stuck loop or a very large catch-up batch.

> **Tip**
>
> You can batch up to **100 readings** in a single POST. Useful for catching up after a network outage: buffer readings locally and send them when connectivity returns. Prefer a steady cadence over large bursts.

### How readings are used

Hungry Machines uses your readings to learn how your home heats and cools and to generate the optimized schedules you pull from [`/api/v1/schedule`](/api-reference/schedule/). Schedules improve as readings accumulate, so keep pushing consistently; gaps slow that down, because there's less to learn from. A stored schedule does not wait for readings: it appears with the first planning run after the appliance is registered (see [Schedule](/api-reference/schedule/#defaults-no-stored-plan-yet)).

Canonical page: https://docs.hungrymachines.io/api-reference/readings/
