> ## 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.

# Smart Charging an EV

> Schedule a charging window, or hand timing to the charger's own price or solar optimiser, then read back which regime it is actually running.

## Overview

There are two ways to charge a car cheaply, and an EV charger surface gives you both.

**You decide when.** Push `charge` with a `start` and an `end`. You own the tariff data, you pick the window, the charger does as it is told. Precise, and only as good as your price feed.

**The charger decides when.** Push one of the three `auto.*` strategies. The charger's own optimiser picks the hours, using whatever it knows that you do not: the tariff the home owner configured in the manufacturer's app, or a live reading of what the solar array is producing right now.

Neither is better. Reach for a window when the timing is yours to own, and a strategy when the charger knows something you cannot see.

<Callout icon="clock" color="#ED6D2C">
  **Coming soon.** Live control for EV chargers. Sandbox serves the full command surface end to end, so the walkthrough below works against a sandbox device today. A live EV charger push returns 503 `NOT_YET_AVAILABLE` until the live path opens.
</Callout>

## The three strategies

| Command                     | What the charger optimises for                                                                             |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `auto.charge_tariff`        | Price. Charges during the cheapest hours of the tariff the home owner has configured.                      |
| `auto.charge_surplus_only`  | Zero import. Charges from on-site generation the home is not using, and pauses when that surplus runs out. |
| `auto.charge_surplus_first` | A full car. Uses that surplus first and tops up from the grid, so charging never pauses.                   |

The two surplus modes answer different questions, and picking the wrong one is the most common mistake here. `auto.charge_surplus_only` accepts that the car may not fill. `auto.charge_surplus_first` accepts an electricity bill. Ask which the driver actually wants before you choose.

A strategy takes **no parameters and no time window**. It runs until another command replaces it. See [why](#why-a-strategy-takes-no-deadline) below.

## Step 1: Check what the charger declares

Read the device before you push. A charger that does not declare a strategy will refuse it, and the read tells you which ones it has.

```bash theme={null}
curl -X GET https://api.amps.ai/ev-charger/device_ev_001 \
  -H "x-api-key: sk_test_xxxxxxxxxxxxxxxxxxxxxxxx"
```

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_ev_001",
    "vendor": "example_vendor_a",
    "sync": { "available": true, "lastPulledAt": "2026-07-30T18:02:00.000Z" },
    "metadata": { "model": "7kW AC Charger", "source": "projection" },
    "state": {
      "status": "available",
      "isConnected": true,
      "isCharging": false,
      "currentPower": 0,
      "maxCurrent": 32,
      "powerRateLimit": 7.4,
      "phases": 1,
      "voltage": 230,
      "notChargingReason": "vehicle",
      "activeControlMode": "idle"
    },
    "conflictStrategies": ["cancel_and_replace", "queue_after"],
    "sessions": true,
    "commands": {
      "charge": {
        "parameters": {
          "power":   { "unit": "kw", "min": 1.4, "max": 22, "step": 0.1 },
          "current": { "unit": "amps", "min": 6, "max": 32, "step": 1 },
          "energy":  { "unit": "kwh", "min": 1, "max": 100 }
        },
        "execution": ["immediate", "scheduled", "windowed"]
      },
      "idle":                      { "parameters": {}, "execution": ["immediate", "scheduled"] },
      "auto.charge_tariff":        { "parameters": {}, "execution": ["immediate", "scheduled"] },
      "auto.charge_surplus_only":  { "parameters": {}, "execution": ["immediate", "scheduled"] },
      "auto.charge_surplus_first": { "parameters": {}, "execution": ["immediate", "scheduled"] }
    },
    "settings": {
      "max_charge_rate":    { "value": 7.4, "unit": "kw", "min": 0, "max": 50, "step": 0.1 },
      "max_charge_current": { "value": 32, "unit": "amps", "min": 6, "max": 32, "step": 1 },
      "cable_lock":         { "value": true }
    },
    "vehicle": { "id": "device_veh_001", "links": { "self": "/vehicle/device_veh_001" } },
    "lastAction": null,
    "currentSchedule": null
  },
  "meta": {
    "requestId": "req_6mR4pKdW",
    "environment": "sandbox",
    "timestamp": "2026-07-30T18:02:00.000Z",
    "latencyMs": 21
  }
}
```

A car is plugged in and nothing is flowing. `notChargingReason: "vehicle"` says the car itself declined, so this is not a fault to surface. A command that is not in `commands` is refused with 422 `UNSUPPORTED_MODE`, and the refusal lists the ones that are.

Note this charger declares no `target` parameter. Stopping at a state of charge means reading it off the car, and most chargers cannot see inside one; they declare `energy` and count on their own meter instead.

## Step 2a: Charge inside a window you choose

Send `start` and `end` as plant-local wall-clock times: `YYYY-MM-DDTHH:MM:SS`, no offset, no `Z`. The platform reads them in the charger's own timezone, so `22:00` means ten at night where the car is parked.

<Tabs>
  <Tab title="curl">
    ```bash theme={null}
    curl -X POST https://api.amps.ai/ev-charger/device_ev_001 \
      -H "x-api-key: sk_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
      -H "Content-Type: application/json" \
      -d '{
        "action": {
          "command": "charge",
          "parameters": { "power": { "value": 7.4, "unit": "kw" } },
          "start": "2026-07-30T22:00:00",
          "end": "2026-07-31T05:30:00"
        },
        "onConflict": "cancel_and_replace"
      }'
    ```
  </Tab>

  <Tab title="Node">
    ```javascript theme={null}
    await fetch("https://api.amps.ai/ev-charger/device_ev_001", {
      method: "POST",
      headers: {
        "x-api-key": process.env.AMPS_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        action: {
          command: "charge",
          parameters: { power: { value: 7.4, unit: "kw" } },
          start: "2026-07-30T22:00:00",
          end: "2026-07-31T05:30:00",
        },
        onConflict: "cancel_and_replace",
      }),
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import os, requests

    requests.post(
        "https://api.amps.ai/ev-charger/device_ev_001",
        headers={
            "x-api-key": os.environ["AMPS_API_KEY"],
            "Content-Type": "application/json",
        },
        json={
            "action": {
                "command": "charge",
                "parameters": {"power": {"value": 7.4, "unit": "kw"}},
                "start": "2026-07-30T22:00:00",
                "end": "2026-07-31T05:30:00",
            },
            "onConflict": "cancel_and_replace",
        },
    )
    ```
  </Tab>
</Tabs>

A push carrying `start` waits in `scheduled` until the window opens. The response echoes both instants normalised to absolute UTC, which is the dispatch-ready form of the wall-clock you sent, not a reinterpretation of it.

```json theme={null}
{
  "success": true,
  "data": {
    "id": "act_ev_2026073018020",
    "deviceId": "device_ev_001",
    "deviceType": "ev_charger",
    "command": "charge",
    "parameters": { "power": { "value": 7.4, "unit": "kw" } },
    "state": "scheduled",
    "createdAt": "2026-07-30T18:02:31.000Z",
    "start": "2026-07-30T21:00:00.000Z",
    "end": "2026-07-31T04:30:00.000Z",
    "links": { "self": "/actions/act_ev_2026073018020" }
  },
  "meta": {
    "requestId": "req_7nS5qLeX",
    "environment": "sandbox",
    "timestamp": "2026-07-30T18:02:31.000Z",
    "latencyMs": 88
  }
}
```

The plant is on British Summer Time, so `22:00` local resolves to `21:00Z`. At `end` the platform takes the command back and the charger returns to whatever it was doing before.

`power` and `current` cap the same rate in different units, so send one, never both. Both together returns 422 `UNSUPPORTED_PARAMETER_COMBINATION`.

## Step 2b: Hand timing to the charger

Same envelope, no parameters, no window.

```bash theme={null}
curl -X POST https://api.amps.ai/ev-charger/device_ev_001 \
  -H "x-api-key: sk_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "action": { "command": "auto.charge_tariff" } }'
```

```json theme={null}
{
  "success": true,
  "data": {
    "id": "act_ev_2026073018050",
    "deviceId": "device_ev_001",
    "deviceType": "ev_charger",
    "command": "auto.charge_tariff",
    "parameters": null,
    "state": "acknowledged",
    "createdAt": "2026-07-30T18:05:12.000Z",
    "start": null,
    "links": { "self": "/actions/act_ev_2026073018050" }
  },
  "meta": {
    "requestId": "req_8oT6rMfY",
    "environment": "sandbox",
    "timestamp": "2026-07-30T18:05:12.000Z",
    "latencyMs": 74
  }
}
```

An immediate push starts in `acknowledged` rather than passing through `scheduled`. The strategy is now armed. Nothing may happen for hours, and that is the mode working, not a failure.

To defer the handover instead, add a `start`: the charger takes over at 18:00 and not before.

```json theme={null}
{ "action": { "command": "auto.charge_tariff", "start": "2026-07-30T18:00:00" } }
```

To stop a strategy, push another command with `onConflict: "cancel_and_replace"`. `idle` pauses charging; `charge` takes direct control back; another `auto.*` swaps the regime. Without `onConflict` the push returns 409, because the strategy is still the charger's standing action. Strategies never stack: one intent governs a charger at a time.

## Step 3: Read back which regime is running

This is the step that makes a smart-charging interface honest. `isCharging` says whether energy is moving. `activeControlMode` says what put it that way.

```json theme={null}
{
  "success": true,
  "data": {
    "id": "device_ev_001",
    "vendor": "example_vendor_a",
    "sync": { "available": true, "lastPulledAt": "2026-07-30T18:06:00.000Z" },
    "metadata": { "model": "7kW AC Charger", "source": "projection" },
    "state": {
      "status": "scheduled",
      "isConnected": true,
      "isCharging": false,
      "currentPower": 0,
      "maxCurrent": 32,
      "powerRateLimit": 7.4,
      "phases": 1,
      "voltage": 230,
      "notChargingReason": "schedule",
      "activeControlMode": "auto.charge_tariff"
    },
    "conflictStrategies": ["cancel_and_replace", "queue_after"],
    "sessions": true,
    "settings": {
      "max_charge_rate":    { "value": 7.4, "unit": "kw", "min": 0, "max": 50, "step": 0.1 },
      "max_charge_current": { "value": 32, "unit": "amps", "min": 6, "max": 32, "step": 1 },
      "cable_lock":         { "value": true }
    },
    "vehicle": { "id": "device_veh_001", "links": { "self": "/vehicle/device_veh_001" } },
    "lastAction": {
      "id": "act_ev_2026073018050",
      "command": "auto.charge_tariff",
      "state": "completed",
      "createdAt": "2026-07-30T18:05:12.000Z",
      "updatedAt": "2026-07-30T18:05:14.000Z",
      "errorCode": null,
      "errorMessage": null,
      "links": { "self": "/actions/act_ev_2026073018050" }
    },
    "currentSchedule": null
  },
  "meta": {
    "requestId": "req_9pU7sNgZ",
    "environment": "sandbox",
    "timestamp": "2026-07-30T18:06:00.000Z",
    "latencyMs": 19
  }
}
```

Plugged in, nothing flowing, `activeControlMode: "auto.charge_tariff"`. The charger is waiting for a cheap hour: `status` reads `scheduled` and `notChargingReason` reads `schedule`, because the charger's own price program is what is vetoing right now. Show the driver "waiting for off-peak", not "not charging".

The same reading with `activeControlMode: "idle"` means somebody stopped it, which is worth telling the driver about. Same stillness, opposite meaning. Branch on `activeControlMode`, never on `isCharging` alone:

```javascript theme={null}
const { state } = (await res.json()).data;

if (state.isCharging) return "Charging";
if (!state.isConnected) return "Not plugged in";
if (state.activeControlMode?.startsWith("auto.")) return "Waiting for the right moment";
if (state.notChargingReason === "authorization") return "Waiting for approval";
return "Paused";
```

`notChargingReason` narrows it further: `vehicle` (the car declined or has finished), `charger` (limiting or curtailing), `authorization` (waiting on an RFID card or app approval), `schedule` (the charger's own program vetoes it), or `unknown`. Of those, only `authorization` is something the driver can act on.

Both `activeControlMode` and `notChargingReason` are absent on a charger that does not report them. Absent is not a value; check the key exists before you read it.

## Why a strategy takes no deadline

A window on `charge` bounds a command you own. A window on a strategy would mean something else: "be ready by then". Meeting that takes a planner that watches the car's level, decides when to draw, and confirms it arrived, and the platform has none of that.

What the window machinery would actually do is turn the strategy on at `start` and send `idle` at `end`. That stops the charge at exactly the moment the driver asked for the car to be finished, which is the opposite of the request. So `auto.*` declares `immediate` and `scheduled` only, and a window on one returns 422 `EXECUTION_NOT_SUPPORTED`. A ready-by parameter is refused for the same reason: 422 `UNSUPPORTED_PARAMETER`.

If you need a hard deadline, own the timing yourself with a windowed `charge`.

## Resolving a collision

One non-terminal action targets a charger at a time. A second push without `onConflict` returns 409 `CONFLICT` naming the conflicting action and the strategies that would resolve it.

`cancel_and_replace` always works. `queue_after` needs the *conflicting* action to have an end to queue behind, so it works behind a windowed `charge` and not behind a strategy, which runs open-ended. Queue behind a strategy and you get 409 with `reason: conflicting_action_not_windowed`, pointing at `cancel_and_replace`.

An action already dispatched and awaiting completion returns 409 `CONFLICT_IN_EXECUTION` with an empty strategy list, which means wait. See [handle a 409 conflict](/guides/cookbook/handle-conflict).

## What next

<CardGroup cols={2}>
  <Card title="Read charging sessions" icon="receipt" href="/guides/cookbook/ev-charging-sessions">
    What each charge actually delivered, and how it was measured.
  </Card>

  <Card title="Cap an EV charger's power" icon="gauge" href="/guides/cookbook/ev-charger-set-power">
    The standing ceiling every session runs under.
  </Card>

  <Card title="EV charger cheat sheet" icon="list-checks" href="/reference/ev-charger-cheat-sheet">
    The whole charger surface in one page.
  </Card>

  <Card title="Canonical actions" icon="book-open" href="/concepts/canonical-actions">
    Where these commands sit in the shared model.
  </Card>
</CardGroup>

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "HowTo",
"name": "Smart Charging an EV",
"description": "Schedule a charging window, or hand timing to the charger's own price or solar optimiser, then read back which regime it is actually running.",
"step": [
{ "@type": "HowToStep", "name": "Step 1: Check what the charger declares", "position": 1 },
{ "@type": "HowToStep", "name": "Step 2: Choose a window or a strategy", "position": 2 },
{ "@type": "HowToStep", "name": "Step 3: Read back which regime is running", "position": 3 }
]
})}}
/>
