# Appliances & Unified Schedules

Register devices, push appliance readings, set constraints and per-HVAC preferences, and pull unified schedules and health.

The appliance endpoints let you register devices (HVAC, EV charger, home battery, water heater, solar, dehumidifier, home robot), push per-device sensor data, set constraints and per-HVAC preferences, and pull schedules and link-health.

> **Caution**
>
> **Free tier:** one HVAC appliance only. Registering additional appliances or non-HVAC device types requires a Premium subscription — the API returns `403 Upgrade required` when the limit is exceeded.

> **Tip**
>
> `entity_id` and the other keys ending in `_entity_id` are opaque identifiers of your choosing. The server stores them and returns them in each schedule's `entities` block so your client knows which device to command; it does not interpret them. `entity_id` must be at least 3 characters. Home Assistant clients put an entity id there, such as `climate.living_room`. **Solar is the exception** — it takes no `entity_id`.

---

## POST /api/v1/appliances

Register a new appliance.

**Auth:** Required

### Required vs. optional

Every registration requires `appliance_type`, `name`, and a `config` object. `appliance_type` is one of `hvac`, `ev_charger`, `home_battery`, `water_heater`, `solar`, `dehumidifier`, `robot`, and cannot be changed later. Which `config` fields are required depends on the type — each table below marks them. A `config` that breaks a rule returns `400` with the offending field name in `detail`.

```bash
curl -X POST https://api.hungrymachines.io/api/v1/appliances \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "appliance_type": "ev_charger",
    "name": "Tesla Model 3",
    "config": {
      "battery_capacity_kwh": 75,
      "max_charge_rate_kw": 7.2,
      "efficiency": 0.9,
      "entity_id": "switch.tesla_charger",
      "soc_entity_id": "sensor.tesla_battery_level"
    }
  }'
```

**Response (201):**

```json
{ "appliance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }
```

### Config shapes by type

#### HVAC

| Field | Type | Required | Constraints |
|---|---|---|---|
| `hvac_type` | string | **Yes** | `"central_ac"`, `"window_ac"`, `"heat_pump"`, `"furnace"`, or `"mini_split"` |
| `home_size_sqft` | integer | **Yes** | 100 – 20 000 |
| `entity_id` | string | **Yes** | Identifier of the thermostat or unit your client commands. At least 3 characters. |
| `indoor_temp_entity_id` | string \| null | No | Identifier of a separate indoor temperature sensor (°F), for a unit that does not report its own room temperature. |
| `indoor_humidity_entity_id` | string \| null | No | Identifier of an indoor relative-humidity sensor. |
| `power_sensor_entity_id` | string \| null | No | Identifier of a sensor reporting the unit's power draw in watts. |
| `setpoint_min_f` | integer \| null | No | Lowest setpoint the unit accepts, 40 – 99 °F. |
| `setpoint_max_f` | integer \| null | No | Highest setpoint the unit accepts, 40 – 99 °F. Must be at least `setpoint_min_f`. When both are set, every setpoint and comfort-band value the schedule serves is kept inside this range. |

#### EV Charger

| Field | Type | Required | Constraints |
|---|---|---|---|
| `battery_capacity_kwh` | float | **Yes** | Greater than 0, up to 300 |
| `max_charge_rate_kw` | float | **Yes** | Greater than 0, up to 50 |
| `efficiency` | float | **Yes** | 0.5 – 1.0 |
| `entity_id` | string | **Yes** | Identifier of the charger your client switches on and off. At least 3 characters. |
| `soc_entity_id` | string \| null | No | Identifier of a sensor reporting state of charge, 0–100 %. |

#### Home Battery

| Field | Type | Required | Constraints |
|---|---|---|---|
| `capacity_kwh` | float | **Yes** | Greater than 0, up to 300 |
| `max_charge_rate_kw` | float | **Yes** | Greater than 0, up to 50 |
| `max_discharge_rate_kw` | float | **Yes** | Greater than 0, up to 50 |
| `entity_id` | string | **Yes** | Identifier of the control your client uses to switch charge mode. At least 3 characters. |
| `soc_entity_id` | string \| null | No | Identifier of a sensor reporting state of charge, 0–100 %. |

#### Home Robot

A robot vacuum or mower. Docking stands in for charging: a `true` slot in the schedule's `intervals` means the robot should be on its dock. Your client only ever sends the robot back to its dock; it never undocks it and never starts a task.

| Field | Type | Required | Constraints |
|---|---|---|---|
| `battery_capacity_kwh` | float | **Yes** | Greater than 0, up to 10 |
| `max_charge_rate_kw` | float | **Yes** | Greater than 0, up to 2 |
| `entity_id` | string | **Yes** | Identifier of the robot your client docks. At least 3 characters. |
| `soc_entity_id` | string \| null | No | Identifier of a sensor reporting battery level, 0–100 %. |

#### Water Heater

| Field | Type | Required | Constraints |
|---|---|---|---|
| `tank_size_gallons` | integer | **Yes** | Greater than 0, up to 200 |
| `element_watts` | integer | **Yes** | Greater than 0, up to 10 000 |
| `insulation_factor` | float | **Yes** | 0.01 – 0.05 |
| `entity_id` | string | **Yes** | Identifier of the heater your client switches on and off. At least 3 characters. |
| `temp_entity_id` | string \| null | No | Identifier of a sensor reporting tank temperature in °F. |

#### Solar

A solar array has no `entity_id` and receives no commands. Registering it lets the schedules for your other appliances account for daytime generation.

| Field | Type | Required | Constraints |
|---|---|---|---|
| `system_size_kw` | float | **Yes** | Greater than 0, up to 100 (DC nameplate) |
| `azimuth_degrees` | integer | No | 0 – 360. Default 180 (south). |
| `tilt_degrees` | integer | No | 0 – 90. Default 20. |
| `production_sensor_entity_id` | string \| null | No | Identifier of a sensor reporting the array's production. If you have one, push production in watts through the appliance readings route below to improve the generation forecast. |

#### Dehumidifier

Dehumidifiers are **recorded only**: no schedule is produced. Push their readings through [`POST /api/v1/readings`](/api-reference/readings/) with the dehumidifier's `appliance_id`, using `hvac_state` `"DRY"` (running) or `"OFF"` (idle).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `entity_id` | string | **Yes** | Identifier of the dehumidifier. At least 3 characters. |
| `indoor_temp_entity_id` | string | **Yes** | Identifier of a room temperature sensor (°F) next to the unit. At least 3 characters. |
| `indoor_humidity_entity_id` | string \| null | No | Identifier of a room relative-humidity sensor. |
| `power_sensor_entity_id` | string \| null | No | Identifier of a sensor reporting power draw in watts. |
| `capacity_pints_per_day` | float \| null | No | Nameplate capacity, greater than 0, up to 200. Informational. |

### Errors

| Status | Detail | Cause |
|---|---|---|
| 400 | `"<field>: <message>"` | Per-type config validation failure, e.g. `"entity_id: Field required"` or `"entity_id: String should have at least 3 characters"` |
| 403 | `"Upgrade required"` | Free-tier limit (1 HVAC only). Checked before the config. |
| 422 | array `detail` | `appliance_type` is not one of the seven types, or `appliance_type` / `name` is missing |
| 503 | `"Database unavailable"` or `"Failed to create appliance"` | The appliance could not be saved |

---

## GET /api/v1/appliances

List all registered appliances.

**Auth:** Required

```bash
curl https://api.hungrymachines.io/api/v1/appliances \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response (200):**

```json
[
  {
    "id": "a1b2c3d4-...",
    "user_id": "550e8400-...",
    "appliance_type": "ev_charger",
    "name": "Tesla Model 3",
    "config": {
      "battery_capacity_kwh": 75,
      "max_charge_rate_kw": 7.2,
      "efficiency": 0.9,
      "entity_id": "switch.tesla_charger",
      "soc_entity_id": "sensor.tesla_battery_level"
    },
    "is_active": true,
    "created_at": "2025-11-15T10:30:00+00:00",
    "optimization_enabled": true
  }
]
```

`optimization_enabled` is the per-appliance pause flag for non-HVAC types; `false` means paused. HVAC units are paused through [per-HVAC preferences](#per-hvac-preferences).

### Errors

| Status | Detail | Cause |
|---|---|---|
| 503 | `"Database unavailable"` | The appliance list could not be read |

---

## PUT /api/v1/appliances/{'{appliance_id}'}

Update an appliance's name, config, or pause flag. All three fields are optional; send only what changes. When updating `config`, **send the full per-type object**, including every required key — `config` replaces what is stored. `appliance_type` cannot be changed.

**Auth:** Required

```bash
curl -X PUT https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-... \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Tesla Model 3 (Garage)",
    "config": { "battery_capacity_kwh": 75, "max_charge_rate_kw": 11.5, "efficiency": 0.9, "entity_id": "switch.tesla_charger", "soc_entity_id": "sensor.tesla_battery_level" }
  }'
```

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | No | New display name |
| `config` | object | No | The complete config for the appliance's type |
| `optimization_enabled` | boolean | No | Pause (`false`) or resume (`true`) a non-HVAC appliance. HVAC units are paused through [per-HVAC preferences](#per-hvac-preferences). |

**Response (200):** Full appliance object (same shape as the GET list item).

**Errors:**

| Status | Detail | Cause |
|---|---|---|
| 400 | `"<field>: <message>"` | Config validation failure, e.g. `"entity_id: Field required"` |
| 404 | `"Appliance not found"` | Doesn't exist, or owned by another user |
| 422 | array `detail` | A field of the wrong JSON type |
| 503 | `"Database unavailable"` or `"Failed to update appliance"` | The change could not be saved |

---

## DELETE /api/v1/appliances/{'{appliance_id}'}

Permanently delete an appliance the caller owns, along with its readings, schedules, and constraints.

**Auth:** Required

```bash
curl -X DELETE https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-... \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response (204):** No body.

**Errors:**

| Status | Detail | Cause |
|---|---|---|
| 404 | `"Appliance not found"` | Doesn't exist, or owned by another user |
| 503 | `"Database unavailable"` or `"Failed to delete appliance"` | The appliance could not be deleted |

---

## POST /api/v1/appliances/{'{appliance_id}'}/readings

Push state readings for a specific non-HVAC appliance (EV charger, home battery, robot, water heater, solar). HVAC and dehumidifier readings go through [`POST /api/v1/readings`](/api-reference/readings/) instead.

**Auth:** Required

### Required vs. optional

`readings` is required and holds 1 to 100 readings. In each reading, `timestamp`, `state`, and `value` are required; `power_watts` and `metadata` are optional.

```bash
curl -X POST https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-.../readings \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "readings": [
      { "timestamp": "2025-11-18T14:30:00+00:00", "state": "CHARGING", "value": 45.2, "power_watts": 7200.0, "metadata": { "voltage": 240, "current": 30 } }
    ]
  }'
```

| Field | Type | Required | Description |
|---|---|---|---|
| `timestamp` | string (ISO 8601) | **Yes** | |
| `state` | string | **Yes** | Free-form label, e.g. `"CHARGING"`, `"IDLE"`, `"ON"`, `"OFF"`, `"DISCHARGING"`. |
| `value` | float | **Yes** | Charge % (0–100) for EV charger, home battery and robot; tank temperature (60–180 °F) for water heater; production watts (0–100000) for solar. Validated per type. |
| `power_watts` | float | No | Current power draw. For solar, 0–100000. |
| `metadata` | object | No | Any additional data, stored as sent |

A batch is accepted or rejected as a whole.

**Response (201):** `{ "accepted": 1 }`

**Daily limit:** an account can push **300 readings per UTC day for each active EV charger, home battery, water heater, solar array or robot it has registered** (floor of one device's worth), counted across all of those appliances together. A batch that would take the day's total past the cap is rejected whole with `429`. One appliance pushing every 5 minutes is 288 a day, comfortably under its 300 share — register more appliances and the cap grows with them. This limit is separate from the one on `POST /api/v1/readings`.

### Errors

| Status | Detail | Cause |
|---|---|---|
| 400 | `"Value must be 0-100 (percent) for ev_charger"` | Charge `value` out of range (also `... for home_battery`, `... for robot`) |
| 400 | `"Value must be 60-180 (degrees F) for water_heater"` | Water heater `value` out of range |
| 400 | `"Value must be 0-100000 (watts) for solar"` | Solar `value` out of range |
| 400 | `"power_watts must be 0-100000 (watts) for solar"` | Solar `power_watts` out of range |
| 404 | `"Appliance not found"` | Bad `appliance_id`, or not owned by the caller |
| 422 | array `detail` | Empty batch, more than 100 readings, or a missing or mistyped field |
| 429 | `"Rate limit exceeded: max <cap> readings per user per day (scaled to <N> active appliance(s))"` | The account's daily total for this route would pass its scaled cap |
| 503 | `"Database unavailable"` or `"Failed to write readings"` | The readings could not be saved |

---

## Constraints (non-HVAC appliances)

Constraints are the limits an EV charger, home battery, robot, or water heater is scheduled within. (HVAC comfort settings use [per-HVAC preferences](#per-hvac-preferences) instead.)

### POST /api/v1/appliances/{'{appliance_id}'}/constraints

Set the constraints. The body is a JSON object that **replaces** the stored constraints, so send the complete set each time. Saving also triggers a recompute of the schedule shortly afterwards; use [`POST /api/v1/schedule/recompute`](/api-reference/schedule/#post-apiv1schedulerecompute) when you want to wait for the result.

An EV charger, home battery or robot gets no schedule until constraints have been saved. A water heater is scheduled on the defaults below until you set its own.

**Auth:** Required

Every key is optional; the default applies when a key is omitted. Keys are validated when present, per appliance type — a key for a type not listed below is stored but never checked, and a validation failure is a `400` naming the field.

| Type | Key | Type | Required | Description |
|---|---|---|---|---|
| EV charger, home battery | `target_charge_pct` | number | No | Charge to reach by the deadline, 0–100. Default 80. |
| EV charger, home battery | `min_charge_pct` | number | No | Charge never to drop below, 0–100, and never above `target_charge_pct` when both are sent. Default 20. |
| EV charger, home battery | `deadline_time` | string | No | `HH:MM`, 24-hour local time (a real hour and minute). Default `"23:59"`. |
| EV charger, home battery | `current_charge_pct` | number | No | Present charge, 0–100. Default 50. Used only when no recent state-of-charge reading has been pushed. |
| Robot | `target_charge_pct` | number | No | Charge to reach before the tasks window, 0–100. Default 90. |
| Robot | `min_charge_pct` | number | No | Charge never to drop below, 0–100, and never above `target_charge_pct` when both are sent. Default 25. |
| Robot | `current_charge_pct` | number | No | Present charge, 0–100. Default 50. Used only when no recent battery reading has been pushed. |
| Robot | `tasks_window_start` / `tasks_window_end` | string | No | `HH:MM`, 24-hour local time (a real hour and minute). Defaults `"09:00"` / `"17:00"`. The window is half-open and may wrap midnight; the robot is expected off its dock during it, and charging is scheduled around it. |
| Water heater | `min_temp_f` | number | No | Lowest tank temperature, °F, 60–180. Default 110. |
| Water heater | `max_temp_f` | number | No | Highest tank temperature, °F, 60–180. Must be above `min_temp_f` when both are sent. Default 140. |

**EV Charger:**
```json
{ "target_charge_pct": 80, "min_charge_pct": 30, "deadline_time": "07:00", "current_charge_pct": 35 }
```

**Home Battery:**
```json
{ "target_charge_pct": 90, "min_charge_pct": 20, "deadline_time": "23:59" }
```

**Water Heater:**
```json
{ "max_temp_f": 140, "min_temp_f": 110 }
```

**Home Robot:**
```json
{ "target_charge_pct": 90, "min_charge_pct": 25, "current_charge_pct": 50, "tasks_window_start": "09:00", "tasks_window_end": "17:00" }
```

**Response (200):** `{ "status": "ok", "constraints": { "..." } }` — `constraints` echoes the object you sent.

**Errors:** `400 "<field>: <message>"` (a key in the table above failed validation for this appliance's type), `404 "Appliance not found"`, `422` (the body is not a JSON object; array `detail`), `503 "Database unavailable"` or `503 "Failed to save constraints"`.

### GET /api/v1/appliances/{'{appliance_id}'}/constraints

Read back the stored constraints so a client can re-display what the user last saved. The appliance list does not include constraints, so this is the read path for EV / battery / water-heater / robot constraints.

**Auth:** Required

**Response (200):** `constraints` is `{}` when none have been set.
```json
{ "status": "ok", "constraints": { "target_charge_pct": 80, "min_charge_pct": 30, "deadline_time": "07:00" } }
```

**Errors:** `404 "Appliance not found"`, `503 "Database unavailable"`.

---

## Per-HVAC Preferences

Each HVAC unit carries its own comfort/savings/mode/pause preferences, so a two-HVAC home holds independent setpoints per unit. The shape mirrors the HVAC-relevant subset of [`/api/v1/preferences`](/api-reference/preferences/); account-level fields (like `newsletter_opt_in`) stay on the account endpoint.

### GET /api/v1/appliances/{'{appliance_id}'}/preferences

**Auth:** Required

```json
{
  "base_temperature": 72.0,
  "savings_level": 1,
  "time_away": "08:00",
  "time_home": "17:00",
  "optimization_mode": "cool",
  "hourly_high_temps_f": null,
  "hourly_low_temps_f": null,
  "optimize_hvac_fan": false,
  "optimize_hvac_mode": false,
  "optimization_enabled": true
}
```

Field semantics match the corresponding fields on [`GET /api/v1/preferences`](/api-reference/preferences/). A unit whose preferences have never been saved returns the account's own current preferences (the standard defaults shown above only if the account hasn't customized them either) — the same values planning for that unit follows until the unit's own preferences are saved.

**Errors:** `404 "Appliance not found"` (missing, not owned, or not an HVAC unit), `503 "Database unavailable"`.

### PUT /api/v1/appliances/{'{appliance_id}'}/preferences

Update per-HVAC preferences. **All fields optional** — fields you do not send keep their saved values. The account-level preferences are left untouched. `optimization_enabled` here is the per-unit pause switch: a unit is optimized only when both it and the account-level master switch are `true`. A successful PUT triggers a debounced recompute.

The first save for a unit makes that unit's own preferences authoritative for every field. On that first PUT, a field you omit is filled in from your current account-level preferences at that moment — not the standard default shown under GET. A later change to your account-level preferences does not retroactively change the unit's own row; send a PUT to this endpoint to update it.

**Auth:** Required

```bash
curl -X PUT https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-.../preferences \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "base_temperature": 70.0, "savings_level": 3, "optimization_mode": "auto", "optimization_enabled": false }'
```

Validation matches [`PUT /api/v1/preferences`](/api-reference/preferences/#put-apiv1preferences).

**Response (200):** Full per-appliance preferences object (same shape as GET).

**Errors:** `400` (hourly-band validation, string `detail`), `404 "Appliance not found"`, `422` (any other field out of range or of the wrong type, array `detail`), `503 "Database unavailable"`.

### GET /api/v1/appliances/{'{appliance_id}'}/preferences/default-bands

Same preview as [`GET /api/v1/preferences/default-bands`](/api-reference/preferences/#get-apiv1preferencesdefault-bands), scoped to this appliance. An omitted query param falls back to this unit's own preferences once they have been saved, otherwise to the account-level preferences.

**Auth:** Required

Query params, validation, and the response shape are identical to the account-level endpoint.

**Errors:** `400` (same validation messages), `404 "Appliance not found"`, `422` (a query value of the wrong type), `503 "Database unavailable"`.

---

## GET /api/v1/appliances/{'{appliance_id}'}/schedule

Get the schedule for a single appliance. Same per-type `schedule` blob as one entry of `GET /api/v1/schedules`, plus the date. When none exists yet, `source` is `"defaults"`, `savings_pct` is `0.0`, and `schedule` is `{}`. `date` is today in the account's local timezone.

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

Every response also carries `optimization_enabled`: the effective pause flag for this appliance, with the account's master switch already folded in. It is present on every appliance type. Do not apply the schedule while it is `false`. A response adds `"stale": true` when the schedule returned is from an earlier day because none has been produced for today.

All schedules use **48 × 30-minute intervals**.

#### HVAC

```json
{
  "appliance_id": "...",
  "appliance_type": "hvac",
  "name": "Living Room AC",
  "date": "2025-11-18",
  "schedule": {
    "intervals": [0, 1, 2, "...", 47],
    "high_temps": [74.0, 74.0, "...(48 values)"],
    "low_temps": [70.0, 70.0, "...(48 values)"],
    "setpoint_temps": [72.0, 71.5, "...(48 values)"],
    "temp_trajectory": [72.1, 71.6, "...(48 values)"]
  },
  "savings_pct": 18.5,
  "source": "optimization",
  "entities": { "entity_id": "climate.living_room", "indoor_temp_entity_id": "sensor.back_bedroom_temperature" },
  "optimization_enabled": true
}
```

**Apply `setpoint_temps[idx]`** at each 30-min boundary regardless of HVAC mode. `high_temps`/`low_temps` are the display-only comfort band; `temp_trajectory` is the display-only predicted line. An appliance with no stored plan yet returns `source: "defaults"` with an empty `schedule` object: there is nothing to apply, so leave the device as it is and check again after the next plan. On a stored plan read each key defensively; if `setpoint_temps` is missing but the band is present, use `(high_temps[idx] + low_temps[idx]) / 2`. HVAC entries also carry an inline `integration_health` block (see [health](#get-apiv1integrationhealth)).

#### EV Charger

```json
{
  "appliance_id": "...",
  "appliance_type": "ev_charger",
  "name": "Tesla Model 3",
  "date": "2025-11-18",
  "schedule": {
    "intervals": [false, false, "...", true, true, "...", false],
    "value_trajectory": [35.0, 35.0, "...", 57.5, 80.0, "...", 80.0],
    "unit": "percent"
  },
  "savings_pct": 32.1,
  "source": "optimization",
  "entities": { "entity_id": "switch.tesla_charger", "soc_entity_id": "sensor.tesla_battery_level" },
  "optimization_enabled": true
}
```

`intervals`: boolean — `true` = charger on. `value_trajectory`: **display-only** predicted charge level (0–100 %).

#### Home Battery

Same shape as EV charger: `intervals` (boolean), `value_trajectory` (percent), `unit: "percent"`, plus `entities`.

#### Home Robot

Same shape as EV charger: `intervals` (boolean), `value_trajectory` (percent), `unit: "percent"`, plus `entities`. `intervals[idx] = true` means "should be on its dock" — docking is the charging proxy, since no robot exposes charge control directly.

#### Water Heater

```json
{
  "appliance_id": "...",
  "appliance_type": "water_heater",
  "name": "Basement Water Heater",
  "date": "2025-11-18",
  "schedule": {
    "intervals": [true, true, false, "...", false, true, "..."],
    "temp_trajectory": [120.0, 128.5, 135.2, "...", 118.3, 126.7, "..."],
    "unit": "fahrenheit"
  },
  "savings_pct": 22.0,
  "source": "optimization",
  "entities": { "entity_id": "switch.water_heater", "temp_entity_id": "sensor.water_heater_temperature" },
  "optimization_enabled": true
}
```

`intervals`: boolean — `true` = element on. `temp_trajectory`: **display-only** predicted tank temperature.

### The `entities` block

Every per-appliance entry carries an `entities` sub-object echoing the identifier keys from the appliance's config, so your client knows which device to command. Only keys the config actually set are included: `entity_id` for everything except solar, plus `soc_entity_id`, `temp_entity_id`, `indoor_temp_entity_id` or `production_sensor_entity_id` when set. It is `{}` when none are set, as for a solar array with no production sensor.

**Errors:** `401` (missing, invalid or expired token), `404 "Appliance not found"`, `503 "Database unavailable"`.

---

## GET /api/v1/schedules

Unified view of every appliance's schedule in one response, and the route that carries the per-slot mode and fan schedules. Pull it after the overnight run, then poll [`GET /api/v1/schedules/updated-at`](#get-apiv1schedulesupdated-at) and pull again whenever its value changes: a plan for today can be replaced during the day.

**Auth:** Required

```bash
curl https://api.hungrymachines.io/api/v1/schedules \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response (200):**

```json
{
  "date": "2025-11-18",
  "appliances": [
    {
      "appliance_id": "...",
      "appliance_type": "hvac",
      "name": "Living Room AC",
      "schedule": {
        "intervals": [0, 1, "...", 47],
        "high_temps": [74.0, "..."],
        "low_temps": [70.0, "..."],
        "setpoint_temps": [72.0, "..."],
        "room_target_temps": [72.0, "..."],
        "temp_trajectory": [72.1, "..."],
        "mode": "cool",
        "generated_at": "2025-11-18T04:15:00+00:00"
      },
      "savings_pct": 18.5,
      "source": "optimization",
      "optimization_enabled": true,
      "entities": { "entity_id": "climate.living_room", "indoor_temp_entity_id": "sensor.back_bedroom_temperature" },
      "integration_health": { "status": "healthy", "message": "Integration is healthy.", "lookback_hours": 6 }
    },
    {
      "appliance_id": "...",
      "appliance_type": "ev_charger",
      "name": "Tesla Model 3",
      "schedule": { "intervals": [false, "...", true, "...", false], "value_trajectory": [30.0, "...", 80.0], "unit": "percent" },
      "savings_pct": 32.1,
      "source": "optimization",
      "optimization_enabled": true,
      "entities": { "entity_id": "switch.tesla_charger" }
    }
  ],
  "optimization_enabled": true,
  "integration_health": { "status": "healthy", "message": "Integration is healthy.", "lookback_hours": 6, "appliances": ["..."] },
  "divergence_summary": { "verdict": "healthy", "human_readable": "All three signals agree — recent readings are clean.", "sample_count": 288 }
}
```

Each entry in `appliances` uses the per-type schedule shape above.

| Field | Type | Notes |
|---|---|---|
| `appliance_id`, `appliance_type`, `name` | string | Identify the appliance and select the schedule shape. |
| `schedule` | object | The stored plan, or `{}` when there is none. |
| `savings_pct` | float | **Display-only.** `0.0` when there is no stored plan. |
| `source` | string | `"optimization"` (a stored plan) or `"defaults"` (no stored plan yet). |
| `stale` | boolean | Present, and `true`, only when there is no plan for today and the most recent stored plan is returned instead. Absent otherwise. |
| `optimization_enabled` | boolean | Effective per-appliance pause flag. On every entry, of every type. |
| `entities` | object | Entity ids from the appliance's config. Always present; `{}` when none are set. |
| `integration_health` | object | HVAC entries only. |

**No stored plan (`source: "defaults"`).** An appliance that has not been planned yet returns `"schedule": {}`, `"savings_pct": 0.0` and `"source": "defaults"`. There is no comfort band and no setpoint on this route to fall back to, so send that appliance no commands and leave it under its own control. If you want a default band to show or hold for an HVAC unit, call [`GET /api/v1/schedule?appliance_id=…`](/api-reference/schedule/), which returns one. The first plan is stored by the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run.

**Present or absent, never `null`.** On this route `schedule` is the stored plan as written, so a schedule key is either present with a value or absent. Check for the key before reading it.

### HVAC schedule fields

| Field | Type | Notes |
|---|---|---|
| `intervals` | int[48] | Always `[0, 1, …, 47]`. |
| `high_temps`, `low_temps` | float[48] | The comfort band (°F). **Display-only.** |
| `setpoint_temps` | float[48] | **The thermostat command.** Apply `setpoint_temps[idx]` at each 30-minute boundary. |
| `room_target_temps` | float[48] | The room temperature the plan aims to hold in each slot (°F). It usually equals `setpoint_temps`; the two differ where the unit's own thermostat reads differently from the room. A client that reads a separate room sensor may adjust the setpoint to steer the room toward this value, and should then report it as `room_target` on its readings. Otherwise ignore it and apply `setpoint_temps`. |
| `hvac_mode_schedule` | string[48] | Per-slot mode: `"OFF"`, `"COOL"` or `"HEAT"`. **When present, apply it** at each boundary along with the setpoint. Present when the unit's `optimize_hvac_mode` preference is on, always for a unit registered with `hvac_type: "window_ac"`, and on a warm-up day. It can be absent on any given plan; when absent, do not change the unit's mode. |
| `fan_mode_schedule` | string[48] | Per-slot fan speed: `"low"`, `"high"` or `"auto"`. **`"auto"` (and `"off"`) means "do not send a fan command"** for that slot; send one only for `"low"` or `"high"`, and only when the unit has a matching fan option. Present when the unit's `optimize_hvac_fan` preference is on and the unit has reported distinct fan speeds in its readings, always for `hvac_type: "window_ac"`, and on a warm-up day. When absent, do not change the fan. |
| `temp_trajectory` | float[48] | Predicted indoor temperature, **display-only**. Absent on a warm-up day. |
| `mode` | string | `"cool"` or `"heat"`. |
| `generated_at` | string | UTC timestamp of when the plan was produced. |

An HVAC schedule can carry further keys. They are display-only and may change without notice; ignore any key not listed here.

**`optimization_enabled` (top level):** the master pause switch. When `false`, **do not apply any appliance's schedule** until a later poll returns `true`.

**`optimization_enabled` (per appliance):** the effective per-unit pause flag (already folds in the master switch). The apply loop should gate on this per appliance. It is on every entry, of every type. A paused unit still has a schedule entry, but do not apply it while the flag is `false`.

**`integration_health`:** an inline link-health verdict so you can show a banner when data stops flowing — same payload as [`GET /api/v1/integration/health`](#get-apiv1integrationhealth), included here to save a round-trip. HVAC entries carry their own inline block; non-HVAC entries omit it.

**`divergence_summary`:** a compact, display-only verdict comparing what your client reported commanding against what the thermostat reports: `verdict` (`healthy`, `entity_unreliable`, `thermostat_ignoring_commands`, `commanded_missing`, `no_data` or `unknown`), `human_readable` (a short description to show) and `sample_count`. It depends on the `commanded_*` fields your client sends with its readings; a client that sends none gets `commanded_missing`.

### Per-type schedule summary

| Type | Schedule fields | Unit |
|---|---|---|
| `hvac` | `intervals` (int[48]), `high_temps` (float[48]), `low_temps` (float[48]), `setpoint_temps` (float[48]), `room_target_temps` (float[48]), `hvac_mode_schedule` and `fan_mode_schedule` (string[48], when they apply), `temp_trajectory` (float[48]), `mode`, `generated_at` — see [HVAC schedule fields](#hvac-schedule-fields) | Fahrenheit |
| `ev_charger` | `intervals` (bool[48]), `value_trajectory` (float[48]), `unit`, plus display-only `min_value`, `target_value` (percent) and `deadline_interval` (slot index) | percent (0–100) |
| `home_battery` | `intervals` (bool[48]), `value_trajectory` (float[48]), `unit`, plus display-only `min_value`, `target_value`, `deadline_interval` | percent (0–100) |
| `robot` | `intervals` (bool[48]), `value_trajectory` (float[48]), `unit`, plus display-only `min_value`, `target_value`, `deadline_interval` | percent (0–100) — `true` means "should be on its dock" |
| `water_heater` | `intervals` (bool[48]), `temp_trajectory` (float[48]), `unit`, plus display-only `high_temps` and `low_temps` (float[48], the tank's allowed range) | fahrenheit |
| `solar` | `generation_kw_48` (float[48], this array's own curve), `unit`, `source` (`"learned"`, `"irradiance"` or `"nameplate"` — a display label for what the generation forecast is based on), `self_consumed_kw_48` (float[48]), `surplus_kw_48` (float[48]), `generation_kwh`, `self_consumed_kwh`, `surplus_kwh`, `export_value_cents` (float or null — null with no export-rate curve), `generated_at` — a forecast to display; there are no per-interval commands | kW |
| `dehumidifier` | _No schedule_ — data-collection only (v1). The entry's `schedule` is `{}`. | — |

Most stored plans also carry `pricing_cents_48` (float[48]), the price curve in cents/kWh the plan was made against, for display.

The `entities` block is always present. It holds only the keys the appliance's config set — `entity_id`, `soc_entity_id`, `temp_entity_id`, `indoor_temp_entity_id`, or `production_sensor_entity_id` on a solar array — and is `{}` when none are set.

To recompute now (e.g. right after the user saves a constraint) and get the same `appliances` entries plus a `results` array, use [`POST /api/v1/schedule/recompute`](/api-reference/schedule/#post-apiv1schedulerecompute). Its response does not carry the top-level `optimization_enabled`, `integration_health` or `divergence_summary`.

---

## GET /api/v1/integration/health

A link-health probe over the recent reading stream, so a dashboard can show a banner when the integration silently stops delivering real data. Returns the same block that `GET /api/v1/schedules` inlines — poll this on a faster cadence (e.g. every 30 s) without pulling the full schedule payload.

**Auth:** Required

**Response (200):**

```json
{
  "status": "frozen_sensor",
  "last_reading_at": "2026-05-29T02:00:00+00:00",
  "last_reading_age_seconds": 312,
  "sample_count": 71,
  "message": "Readings are arriving on time but indoor temperature has been stuck at 70.0°F for 6 hours. Your thermostat is likely disconnected, or your client's sensor mapping has changed.",
  "lookback_hours": 6,
  "appliances": [
    { "appliance_id": "1111...", "name": "Living Room AC", "status": "healthy", "message": "Integration is healthy.", "lookback_hours": 6 },
    { "appliance_id": "2222...", "name": "Bedroom AC", "status": "frozen_sensor", "message": "...", "lookback_hours": 6 }
  ]
}
```

| Field | Type | Notes |
|---|---|---|
| `status` | string | See table below |
| `last_reading_at` | string \| null | ISO timestamp of the most recent reading in the window |
| `last_reading_age_seconds` | int \| null | Age of the most recent reading |
| `sample_count` | int | Readings in the lookback window. Readings from a paused appliance are left out |
| `message` | string | Short human-readable description, written to name no product or platform — safe for any client to render verbatim, or key its own wording on `status` instead. |
| `lookback_hours` | int | Window inspected (currently 6 h) |
| `appliances` | array | Per-HVAC breakdown (`appliance_id`, `name`, plus the fields above). Non-HVAC units omitted; empty when no HVAC is registered. |

| `status` | Meaning |
|---|---|
| `healthy` | Recent readings present and indoor temperature varying. |
| `stale_data` | Last reading > 30 minutes old. Readings have stopped arriving. |
| `frozen_sensor` | Readings arriving on cadence but indoor temperature is flatlined. Sensor likely disconnected or the entity renamed. |
| `no_data` | Zero readings in the window. The client is offline. Also returned, with an explanatory `message`, when recent readings could not be read. |

This route answers `200` with a `status` in every case; it has no error status of its own beyond the auth errors.

> **Note**
>
> The response may include additional diagnostic counters. They're informational and may change — render `status` and `message`, and don't build logic on undocumented fields.

**Errors:** `401` (`"Not authenticated"`, `"Invalid token"` or `"Token expired"`).

---

## GET /api/v1/schedules/history

Return the history of schedule rows for the account's appliances in a date range. `GET /schedule` and `/schedules` always return the **latest** schedule per `(appliance, date)`; this returns every stored one, so a UI can show how the plan for a day changed.

**Auth:** Required

### Query parameters

| Param | Required | Notes |
|---|---|---|
| `appliance_id` | No | Omit for all appliances the account owns. |
| `start` | No | Date, exactly `YYYY-MM-DD`. Default: `end - 14 days`. |
| `end` | No | Date, exactly `YYYY-MM-DD`. Default: today. |

`end - start` must be ≤ 90 days.

**Response (200):**

```json
{
  "start": "2026-05-15",
  "end": "2026-05-29",
  "appliances": [
    {
      "appliance_id": "0a0feccb-...",
      "appliance_type": "hvac",
      "name": "Bedroom AC",
      "history": [
        {
          "id": 79,
          "appliance_id": "0a0feccb-...",
          "date": "2026-05-14",
          "schedule": { "setpoint_temps": ["..."], "temp_trajectory": ["..."] },
          "constraints_used": { "optimization_mode": "cool", "base_temperature": 72.0 },
          "baseline_cost": 0.42,
          "optimized_cost": 0.31,
          "savings_pct": 26.1,
          "created_at": "2026-05-14T21:35:04Z"
        }
      ]
    }
  ]
}
```

Each entry carries `id`, `appliance_id`, `date`, `schedule` (the same object `GET /api/v1/schedules` returns for that appliance type), `constraints_used`, `baseline_cost`, `optimized_cost`, `savings_pct` and `created_at`. Entries are sorted by `date` ascending, then `created_at` ascending, so the last entry for a date is the one the schedule routes serve. `baseline_cost` / `optimized_cost` (dollars) and `savings_pct` are **display-only**.

`constraints_used` is a fixed, documented field set: whichever of `optimization_mode`, `base_temperature`, `savings_level`, `max_temp_f`, `min_charge_pct`, `target_charge_pct`, `deadline_time`, `tasks_window_start`, `tasks_window_end` and `nameplate_kw` apply to this appliance's type (present when a value was recorded for this run, absent otherwise). Ignore any key not in this list.

**Errors:**

| Status | Detail | Cause |
|---|---|---|
| 400 | `"window exceeds 90 days"` / `"start must be <= end"` | Bad date range |
| 400 | `"start must be an ISO date (YYYY-MM-DD)"` / `"end must be an ISO date (YYYY-MM-DD)"` | `start`/`end` is not a valid ISO date |
| 401 | `"Not authenticated"`, `"Invalid token"` or `"Token expired"` | No `Authorization` header, or a bad or expired token |
| 404 | `"appliance not found for this user"` | Only when `appliance_id` is set |

## GET /api/v1/schedules/updated-at

Lightweight freshness check: returns the latest timestamp a schedule was written for the account, for today, without downloading the schedules themselves. **This is how a client notices a daytime re-plan.** Poll it every few minutes, keep the last value you saw, and fetch [`GET /api/v1/schedules`](#get-apiv1schedules) again when it changes.

**Auth:** Required

**Response (200):**

```json
{ "updated_at": "2026-09-05T14:32:07.123456+00:00" }
```

`updated_at` is `null` when the account has no appliances, or nothing has been scheduled for today yet.

**Errors:**

| Status | Detail | Cause |
|---|---|---|
| 401 | `"Not authenticated"`, `"Invalid token"` or `"Token expired"` | No `Authorization` header, or a bad or expired token |

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