> ## Documentation Index
> Fetch the complete documentation index at: https://docs.amps.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Device State

> Three-tier device state: cache, live OEM call, last-known fallback. Sync metadata, source provenance, expedited refresh.

`GET /battery/{deviceId}` returns the device's current state, its capability declaration, and metadata identifying where the data came from. The retrieval path is identical across device types; only the state schema and command set change.

## The response envelope

Every response is wrapped: a successful read carries the device under `data`, alongside a `meta` block with `requestId`, `environment`, `timestamp`, and `latencyMs`. A list response carries `{items, pagination}` under `data`. The shape never varies.

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_abc123",
    "vendor": "FoxESS",
    "sync": { "available": true, "lastPulledAt": "2026-06-01T10:14:23.000Z" },
    "metadata": { "model": "H1-5.0-E", "source": "cache", "cacheType": "normal" },
    "state": {
      "status": "charging",
      "level": 50,
      "capacity": 10.4,
      "chargeRate": 2.4,
      "dischargeLimit": 10,
      "currentMode": "charge"
    },
    "conflictStrategies": ["cancel_and_replace", "queue_after"],
    "commands": {
      "charge": { "parameters": { "power": { "unit": "kw", "min": 0, "max": 5 } }, "execution": ["immediate", "scheduled", "windowed"] },
      "auto.balance": { "parameters": {}, "execution": ["immediate", "scheduled"] }
    },
    "settings": {
      "discharge_floor": { "value": 10, "unit": "percent", "min": 0, "max": 100 }
    },
    "lastAction": {
      "id": "action_def456",
      "command": "charge",
      "state": "completed",
      "createdAt": "2026-06-01T09:15:00.000Z",
      "updatedAt": "2026-06-01T09:18:00.000Z",
      "errorCode": null,
      "errorMessage": null,
      "links": { "self": "/actions/action_def456" }
    },
    "currentSchedule": null
  },
  "meta": {
    "requestId": "req_8sW2dRtX",
    "environment": "live",
    "timestamp": "2026-06-01T10:14:23.000Z",
    "latencyMs": 32
  }
}
```

In code, unwrap before reading:

```javascript theme={null}
const body = await res.json();
const device = body.data;
console.log(device.state.status);     // "charging"
```

`state` is the live snapshot. `commands` and `settings` are computed from the device's declared capabilities at read time and merged with live values. See [capabilities](/concepts/capabilities) for the full surface.

### Reading `state`: what versus what was asked

`state.status` is what the device is doing right now: a battery's `status` is one of `charging`, `discharging`, `idle`, or `standby`; an HVAC `state.active` flag answers the same question for a thermostat (heating or cooling). `state.currentMode` (battery) is the standing command the device is operating under. The two can split cleanly: a `charge` whose `target` has already been met reads `status: "idle"`, `currentMode: "charge"`, `chargeRate: 0`. The command stands, the floor of the requested action is satisfied, no energy is moving. Push a higher target to see it charge again.

An EV charger splits the same way, with `activeControlMode` playing the battery's `currentMode`. `isCharging` says whether energy is moving; `activeControlMode` says which regime put it that way. A charger sitting still under `auto.charge_tariff` because the price is high reads `isCharging: false`, `activeControlMode: "auto.charge_tariff"`. One a driver stopped reads `activeControlMode: "idle"`. Same stillness, opposite meaning, and only the second is something to tell the driver about.

`notChargingReason` answers the follow-up when a car is plugged in and not drawing: `vehicle` (the car declined or has finished), `charger` (the charger is limiting or curtailing), `authorization` (waiting on an RFID card or app approval), `schedule` (the charger's own program vetoes charging right now), or `unknown` (paused, cause not given). Only `authorization` is something the driver can act on directly.

### Optional readings are absent, never zero

An EV charger's `sessionEnergy`, `phases`, `voltage`, `notChargingReason`, and `activeControlMode` are presence-based: a key is missing because this charger does not report that reading, never because the reading happens to be zero. A single-phase charger reporting `phases: 1` and a charger that does not report phases at all are different facts, and the response keeps them apart. Check for the key before reading the value.

`notChargingReason` draws that line twice. Its absence means the charger said nothing about why it is paused; `unknown` means the charger reported a pause and would not name the cause. Do not collapse the two.

`status: "discharging"` is not a charging state. A charger exporting from the car back to the home or the grid reports `discharging` with a negative `currentPower` and `isCharging: false`. Sum `currentPower` across chargers as a signed number, or an exporting charger will read as load.

`lastAction` and `currentSchedule` let one read answer "what did Amps last tell this device to do, and what governs it", so you can render a device card in a single call. Both are summaries with a link, not full copies: follow `lastAction.links.self` to `GET /actions/{actionId}` for the complete record (parameters, full result, all timestamps), and the device's own [actions](/concepts/canonical-actions) all appear in `GET /actions`. The summary carries the canonical `command`, the lifecycle `state`, `createdAt`, `updatedAt` (the most recent state transition), and `errorCode`/`errorMessage` (populated on a failed action, null otherwise) — enough to render the card without a second call. `lastAction.command` is the same canonical verb you sent, and always matches the full record. Reading `lastAction` against the live `state` is also how you catch a third-party override: when the device is doing something other than what Amps last commanded, something else moved it. `lastAction` is `null` until the device has its first action, and a just-pushed action may take a moment to appear here; if you have just written and need the up-to-date lifecycle state immediately, fetch the action by id at `links.self`.

`currentSchedule` is the schedule governing the device. It is `null` for now and arrives with the scheduler: the `GET /schedules` and `GET /{type}/{id}/schedule` routes return `501` until then. Following a schedule already works today through the HVAC `auto.schedule` command.

## Three-tier retrieval (live)

In **live**, reads pass through three tiers in order. Sandbox skips this entirely; see [sandbox versus live](#sandbox-versus-live) below.

| Tier     | `metadata.source` | When                                    |
| -------- | ----------------- | --------------------------------------- |
| Cache    | `cache`           | A recent read is available.             |
| Live     | `live`            | No recent read, or expedited request.   |
| Fallback | `fallback`        | Live call fails with a transient error. |

```text theme={null}
GET /battery/{id}     (live)
  -> Cache (time-bounded)
    -> miss or expedite
      -> Live OEM API
        -> transient failure -> stored last-known
```

Cache and live both report `sync.available: true`. Fallback reports `sync.available: false` so you know the data is not authoritative for the current request. The `metadata.source` field names the tier explicitly.

A fallback reading can additionally carry `metadata.degraded: true`. This is narrower than the `fallback` source on its own: it means Amps has a protective circuit open for this OEM and is deliberately serving last-known state rather than calling the device, not that a single live call happened to fail. The flag is present only when true, so a normal reading omits it entirely. Read it as a signal that the OEM is under active protection, so a retry keeps returning last-known until `metadata.source` reports `live` again.

In **sandbox**, none of the above applies. Sandbox devices are simulated, not physical hardware, so reads always carry `metadata.source: "projection"` and never report `cache`, `live`, or `fallback`. The reported state reflects the commands you have sent, so the next read after a push is consistent with it. See [sandbox versus live](#sandbox-versus-live).

## Cache modes (live): normal and expedited

In **live**, two read tempos exist. They control how close to real time the response sits versus how often the OEM gets called.

| Mode      | Freshness            | When to use                                            |
| --------- | -------------------- | ------------------------------------------------------ |
| Normal    | Up to 15 minutes old | Default. Covers dashboard reads, periodic checks.      |
| Expedited | Up to 1 minute old   | Triggered by `?expedite=true`. Forces near-fresh data. |

A normal request prefers a fresher expedited entry when one exists. Both tiers are checked; whichever is freshest returns. An expedited request goes live on miss; it does not fall back to a 15-minute-old normal cache, because the explicit signal was "I want fresh data".

In live, the cache window is time-bounded only. A push completing does not refresh a stale read for free, so polling the device immediately after a write may return the same view you read before. Two ways to handle this in live, depending on which way you want the trade-off:

* Subscribe to `push.completed` for the action and react to that, instead of polling the device read.
* If you need a fresh read in live right now, call with `?expedite=true`.

<Tip>
  In **sandbox** these caveats do not apply: the simulation reflects your latest command on the next read. The cache caveats only exist on the live path. See [sandbox versus live](#sandbox-versus-live).
</Tip>

## Transient versus permanent errors

OEM errors are classified:

| Class     | Codes                                                                              | Behaviour                             |
| --------- | ---------------------------------------------------------------------------------- | ------------------------------------- |
| Transient | `NETWORK_ERROR`, `RATE_LIMITED`, `SERVICE_UNAVAILABLE`, `TIMEOUT`, `UNKNOWN_ERROR` | Fall back to last-known stored state. |
| Permanent | `DEVICE_NOT_FOUND`, `DEVICE_OFFLINE`                                               | Return to caller. No fallback.        |

Permanent errors mean the device is definitively unknown for this request, and stale fallback data would mislead you. Transient errors mean the OEM is briefly unavailable, so last-known state is the best answer Amps can give. `metadata.source: 'fallback'` plus `sync.available: false` make the difference visible.

If no data exists at any tier (empty cache, failing OEM call, no last-known record), the API returns 404 `DEVICE_NOT_FOUND`.

## What is live versus cached

The `state` object is the live or cached device state. The `commands` and `settings` objects merge capability declarations with live values: a setting's bounds come from its declared capabilities, its current value comes from the device. Capability declarations themselves take effect on the next read; there is no separate cache invalidation for capability data.

You see canonical names only; OEM-native identifiers never appear in the response.

## Polling cadence and OEM rate limits (live)

In live, per-OEM rate limits are honoured automatically. A burst of expedited reads against the same device coalesces; the platform enforces a minimum gap, so short read intervals paired with high volumes will not inadvertently flood the OEM.

For state changes that require sub-minute reaction, prefer [webhooks](/concepts/webhook-model) over polling. `push.completed` and `push.failed` events deliver to your registered endpoint with retry and signature verification built in. Polling is the right default for dashboard-style displays, where the visible freshness window is acceptable.

In sandbox, no OEM is being called and no rate limits apply. Read as often as your test loop needs.

## Per-device-type fields

Each device type exposes its own state schema. The shape is identical across every supported device of the same type; differences in OEM physics (does this battery report a cell-temperature breakdown? does this inverter expose per-string voltages?) surface as nullable fields when the OEM does not populate them.

| Device type    | Key state fields                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Battery        | `status` (`charging` / `discharging` / `idle` / `standby`), `level`, `capacity`, `chargeRate`, `dischargeLimit`, `currentMode` (the standing command in canonical vocabulary, e.g. `charge`, `auto.balance`)                                                                                                                                                                                                                                                                                                                        |
| EV charger     | `status` (`available` / `charging` / `discharging` / `scheduled` / `error` / `offline`), `isConnected`, `isCharging`, plus the optional `currentPower` (signed), `maxCurrent`, `powerRateLimit`, `sessionEnergy`, `phases`, `voltage`, `notChargingReason`, and `activeControlMode` (the standing strategy in canonical vocabulary, e.g. `charge`, `auto.charge_tariff`). The three power readings are absent on brands whose API carries no power, current or ceiling field — a charger that is simply drawing nothing reports `0` |
| HVAC           | `temperature`, `active` (currently heating or cooling), `heatSetpoint`, `coolSetpoint`, `mode` (`heat` / `cool` / `auto` / `off`), `holdType` (`permanent` if a manual mode is overriding the device's schedule, `schedule` if it is honouring its programmed schedule)                                                                                                                                                                                                                                                             |
| Solar inverter | `status`, `currentPower`, `producing`, `energyTotal`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Vehicle        | `batteryLevel`, `range`, `plugged`, `charging`, `fullyCharged` (the battery has reached its configured `chargeLimit`), `batteryCapacity` (usable kWh), `chargeLimit` (configured upper-bound percentage), `chargeRate`, `chargeTimeRemaining` (minutes to reach `chargeLimit`), `maxCurrent` (amps the vehicle will accept)                                                                                                                                                                                                         |

**Solar inverters and vehicles are read-only.** They report state but take no imperative commands, so they publish no push endpoint and carry no `commands` map. Use them for monitoring, dashboards, and fleet-wide reads.

## Listing devices

`GET /{deviceType}` returns a paginated list of devices your API key can access in the current environment. Each entry carries its last known state, and the same `lastAction` + `currentSchedule` summaries as the single read, so a fleet view can show what every device is doing without a request per device. Filter by `userId` to scope to one end-user; paginate via `limit` (1-50, default 10) and `offset` (default 0). The list view uses cached state; for fresh data on a single device, hit the device-id endpoint with `?expedite=true`.

## Sandbox versus live

Sandbox and live share the response shape, the canonical vocabulary, auth, and access control. They differ on what sits behind the read.

### Sandbox: the simulation reflects your latest command

In sandbox, the canonical actions you push *are* the device. Push `charge` and the next read returns `status: "charging"` with `level` climbing toward the target. Push `idle` and the next read returns idle. Push nothing and the device drifts on its baseline (a solar curve at midday, a battery cycling on the household pattern). Reads and pushes against the same sandbox device stay consistent without polling intervals or cache waits: sandbox devices are simulated, so the next read after a push reflects that command. The read carries `metadata.source: "projection"` to mark the state as simulated rather than from real hardware.

Because each read reflects the simulation's current state, a value still in motion advances between two reads taken moments apart: a charging battery's `level` might read `51` then `52`. Compare a read against the command you sent, not against an exact earlier number.

Windowed commands behave correctly over time. A command carrying both `start` and `end` applies across its window, and the simulated state reverts to a hold once `end` passes: a read inside the window shows it active, a read afterward shows the device idle, holding whatever `level` or `temperature` the window left behind.

In sandbox `state` and `lastAction` always agree: the read reflects the action you just sent. The cache caveats described above are live-only; they do not apply to sandbox reads.

### Live: the read reflects whatever the OEM reports

A live `GET /battery/{id}` reflects whatever the real OEM reports for the device on the other end, which may differ from the last command Amps sent (a homeowner toggled the panel, the OEM denied the write silently, the device is rate-limited). The `lastAction` summary against the live `state` lets you see when those diverge. Cache modes, the three-tier retrieval path, and the `?expedite=true` query are all properties of the live read.

See [auth and environments](/concepts/auth-model) for how the API key prefix routes you between them.

## Frequently asked questions

### How fresh is the data returned by GET /battery/{id}?

In **sandbox**, the next read after a push reflects that command right away; there are no cache windows to wait through, and `metadata.source` is always `projection`. In **live**, the default read can be up to 15 minutes old; append `?expedite=true` to drop to near-fresh (up to 1 minute old) at the cost of an OEM round trip. The `metadata.source` field names the tier the live response came from (`cache`, `live`, or `fallback`).

### What is the difference between live data and cached data?

These tiers apply on live only. Live data (`metadata.source: "live"`) is fetched from the OEM at request time. Cached data (`source: "cache"`) is served from a previous fetch and may be older than the previous read. Both indicate `sync.available: true` because the data is authoritative for what the device most recently reported. Fallback data (`source: "fallback"`), served from the last-known stored state when a fresh OEM call fails with a transient error, indicates `sync.available: false` so you know the reading is not fresh. Sandbox device reads sit outside this scheme entirely and carry `source: "projection"` because sandbox devices are simulated, not physical hardware.

### How do I detect when a device is offline?

The platform classifies OEM errors as transient or permanent. `DEVICE_OFFLINE` is permanent and returns to the caller without falling back to stale data; the response has the standard error envelope and a 4xx-class status. Transient errors (`NETWORK_ERROR`, `RATE_LIMITED`, `SERVICE_UNAVAILABLE`, `TIMEOUT`) fall back to last-known and set `sync.available: false`. Offline detection is a property of the live read path; in sandbox the simulator is always reachable.

### After a push completes in live, why might the next read still look stale?

In live, reads are served from a cache by default and a push completing does not refresh that cache on its own. Either subscribe to `push.completed` for the action and react to that, or call the read with `?expedite=true` when freshness matters more than the OEM rate limit. In sandbox, this caveat does not apply: the next read reflects the latest command.

### Why does GET /battery (the list endpoint) not show fresh live data?

Listing devices returns up to 50 entries per page; calling the OEM API for each device on every list request would multiply per-OEM load by the number of devices. The list view in live uses cached state. For fresh data on a single device, fetch the device-id endpoint with `?expedite=true`. The list is shaped for dashboards and inventories; the per-device read is shaped for control loops. In sandbox, the list reflects each device's most recent command alongside its drift baseline, the same as the single-device read.

## Related concepts

<CardGroup cols={2}>
  <Card title="Capabilities" icon="list-checks" href="/concepts/capabilities">
    The `commands` and `settings` objects on the response.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/concepts/webhook-model">
    State-change notifications without polling.
  </Card>

  <Card title="Auth and Environments" icon="key" href="/concepts/auth-model">
    Sandbox versus live, environment routing.
  </Card>

  <Card title="Error Envelope" icon="circle-alert" href="/concepts/error-envelope">
    `DEVICE_NOT_FOUND`, transient versus permanent.
  </Card>
</CardGroup>

For a read-then-push walkthrough, see the cookbook: [schedule an overnight charge](/guides/cookbook/schedule-charge-later).

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
__html: JSON.stringify({
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: [
    {
      "@type": "Question",
      name: "How fresh is the data returned by GET /battery/{id}?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "In sandbox, the next read after a push reflects that command right away, and metadata.source is always projection. In live, the default read can be up to 15 minutes old; append ?expedite=true to drop to near-fresh (up to 1 minute old) at the cost of an OEM round trip. The metadata.source field names the tier the live response came from (cache, live, or fallback)."
      }
    },
    {
      "@type": "Question",
      name: "What is the difference between live data and cached data?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "These tiers apply on live only. Live data (metadata.source: live) is fetched from the OEM at request time. Cached data (source: cache) is served from a previous fetch and may be older than the previous read. Both indicate sync.available: true because the data is authoritative for what the device most recently reported. Fallback data (source: fallback), served from the last-known stored state when a fresh OEM call fails with a transient error, indicates sync.available: false so you know the reading is not fresh. Sandbox device reads sit outside this scheme entirely and carry source: projection because sandbox devices are simulated, not physical hardware."
      }
    },
    {
      "@type": "Question",
      name: "How do I detect when a device is offline?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "The platform classifies OEM errors as transient or permanent. DEVICE_OFFLINE is permanent and returns to the caller without falling back to stale data; the response has the standard error envelope and a 4xx-class status. Transient errors (NETWORK_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE, TIMEOUT) fall back to last-known and set sync.available: false. Offline detection is a property of the live read path; in sandbox the simulator is always reachable."
      }
    },
    {
      "@type": "Question",
      name: "After a push completes in live, why might the next read still look stale?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "In live, reads are served from a cache by default and a push completing does not refresh that cache on its own. Either subscribe to push.completed for the action and react to that, or call the read with ?expedite=true when freshness matters more than the OEM rate limit. In sandbox, this caveat does not apply: the next read reflects the latest command."
      }
    },
    {
      "@type": "Question",
      name: "Why does GET /battery (the list endpoint) not show fresh live data?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "Listing devices returns up to 50 entries per page; calling the OEM API for each device on every list request would multiply per-OEM load by the number of devices. The list view in live uses cached state. For fresh data on a single device, fetch the device-id endpoint with ?expedite=true. The list is shaped for dashboards and inventories; the per-device read is shaped for control loops. In sandbox, the list reflects each device's most recent command alongside its drift baseline, the same as the single-device read."
      }
    }
  ]
})
}}
/>
