# Condition data from BMS and service

> A battery management system or a service tool reports measurements per unit with updateCondition; newer measurements are accepted, older ones skipped, and the passport is re-published by your policy.

Dynamic battery data (state of health, capacity fade, full cycles, temperatures) comes from the battery management
system (BMS), a monitoring platform or a service tool, sometimes through the ERP's service module. In this workflow
the middleware reports it per unit with [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition).
The details of the operation are in [Push condition data](https://passport.kabin.sh/docs/en/guides/push-condition-data); this page shows
the workflow around it.

```mermaid
sequenceDiagram
  participant BMS as BMS or service tool
  participant MW as Middleware
  participant BP as BatteryPass API
  BMS->>MW: new measurements per serial number
  MW->>MW: look up the BatteryPass id in its own mapping
  alt serial not mapped yet
    MW->>BP: listBatteries with serial_prefix (scope read)
    BP-->>MW: the unit
  end
  MW->>BP: updateCondition with measured_at (scope condition)
  BP-->>MW: accepted, skipped, publication
  BP->>BP: condition_update event naming the key, re-publication by policy
```

**Trigger:** The BMS or the service tool has new measurements of a unit.

**Key scopes:** read, condition

| # | Who         | What                                                                                                                                                                                                                    | Operation                                                                                                               |
| - | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 1 | Middleware  | Collects the new measurements per unit with the time of measurement.                                                                                                                                                    |                                                                                                                         |
| 2 | Middleware  | Finds the unit by its serial number (filter serial_prefix) and keeps the BatteryPass id in its own mapping table.                                                                                                       | [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries.md) (Scope: read)            |
| 3 | Middleware  | Reports the values with measured_at; older or equally old measurements are skipped, so a retry does no harm.                                                                                                            | [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition.md) (Scope: condition) |
| 4 | BatteryPass | Stores the accepted values as measurements (approved without a reviewer, ADR-016), records a condition_update lifecycle event that names the key, and re-publishes immediately or daily as the organisation has set it. |                                                                                                                         |

**Security**

- A key of its own per BMS platform or service partner, with the scopes condition and read.
- On 429 the middleware waits the seconds of Retry-After, otherwise it retries with exponential backoff and jitter.

**Data minimisation**

- Only the unit's dynamic fields; no location data, no vehicle or owner data.

## The key

One key per BMS platform or service partner, with the scopes `condition` (to report) and `read` (to find units).
Two keys, one per scope, work just as well. A `condition` key can report for every unit of your organisation, so
hand a key to a service partner only if you would let the partner do that.

## Find the unit once

The middleware keeps a mapping table from serial number to the BatteryPass id. For a serial it does not know yet,
it asks [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries) with `serial_prefix` and picks the unit
whose `serial_number` matches exactly (serial numbers are unique per model; add `battery_model_id` when two models
share a numbering scheme):

```bash title="cURL"
curl -sS "$BATTERYPASS_URL/api/v1/batteries?serial_prefix=SV2027-000184&limit=10" \
  -H "Authorization: Bearer $BATTERYPASS_BMS_KEY" | jq '.items[] | select(.serial_number == "SV2027-000184") | .id'
```

## Report the measurements

```bash title="cURL"
curl -sS -X PATCH "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/condition" \
  -H "Authorization: Bearer $BATTERYPASS_BMS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"measured_at": "2026-10-24T06:00:00Z", "note": "BMS daily report",
       "values": [{"field_key": "condition.capacity_fade", "value": 3.5},
                  {"field_key": "condition.number_of_full_cycles", "value": 412}]}'
```

```ts title="TypeScript"
const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_BMS_KEY}`, 'Content-Type': 'application/json' };

type Reading = { fieldKey: string; value: number; measuredAt: string };

export async function report(batteryId: string, readings: Reading[]) {
  const body = {
    note: 'BMS daily report',
    values: readings.map((r) => ({ field_key: r.fieldKey, value: r.value, measured_at: r.measuredAt })),
  };
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${BASE}/batteries/${batteryId}/condition`, { method: 'PATCH', headers, body: JSON.stringify(body) });
    if (res.ok) return res.json(); // { accepted, skipped, publication, event }
    const problem = await res.json().catch(() => ({}));
    const retry = res.status === 429 || res.status >= 500;
    if (!retry || attempt >= 4) throw new Error(`updateCondition: ${res.status} ${problem.code} (request_id ${problem.request_id})`);
    const wait = res.status === 429 ? Number(res.headers.get('Retry-After') ?? 1) * 1000 : 2 ** attempt * 1000;
    await new Promise((r) => setTimeout(r, wait + Math.random() * 500)); // backoff with jitter
  }
}
```

```python title="Python"
import os
import random
import time

import requests

BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_BMS_KEY"]


def report(battery_id, readings):
    """readings: [(field_key, value, measured_at)]; returns accepted, skipped, publication and event."""
    body = {
        "note": "BMS daily report",
        "values": [{"field_key": k, "value": v, "measured_at": at} for k, v, at in readings],
    }
    for attempt in range(5):
        res = session.patch(f"{BASE}/batteries/{battery_id}/condition", json=body, timeout=30)
        if res.ok:
            return res.json()
        if res.status_code != 429 and res.status_code < 500:
            problem = res.json()
            raise SystemExit(f"updateCondition: {res.status_code} {problem['code']} (request_id {problem.get('request_id')})")
        wait = int(res.headers.get("Retry-After", "1")) if res.status_code == 429 else 2**attempt
        time.sleep(wait + random.random() / 2)  # backoff with jitter
    raise SystemExit("updateCondition: gave up after 5 attempts")
```

Because a measurement equal to or older than the stored one is skipped, sending the same reading again after a
timeout changes nothing: this is what makes the retry above safe. Send measurements of one unit in the order they
were taken.

## What BatteryPass does with them

- Accepted values are stored as **approved measurements without a reviewer** (measured data has no review step) with
  their `measured_at`.
- The update records a `condition_update` lifecycle event; that event names the key (`api_key`) and has the source
  `api`. The values themselves do not carry the key. The audit log names the key as the actor.
- A published passport is re-published as your organisation's setting says: at once (`publication.outcome` is
  `published` or `unchanged`) or by the daily job (`scheduled`). `blocked` means a required value is missing now;
  the passport is `update_needed` and a member must act.

## What never flows

Only the unit's dynamic fields (registry section `condition`). No location or GPS data, no vehicle or owner data,
no operator names. A note is free text that ends up in the unit's history: fill it from a fixed template.

## Failure handling

| Answer                 | Meaning                                                                                                                                                                  | What to do                                                           |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `400 validation_error` | A field that is not a dynamic field of the unit (`unknown_field`), a value outside its range, a time in the future or before manufacture, or a failing cross-field check | Fix the reading; the whole update was refused.                       |
| `401 unauthenticated`  | Key missing, unknown or revoked                                                                                                                                          | Check the secret; alert.                                             |
| `403 forbidden`        | The key lacks the scope `condition`                                                                                                                                      | Use the right key.                                                   |
| `404 not_found`        | The unit is not in your organisation                                                                                                                                     | Refresh the mapping table.                                           |
| `409 conflict`         | The unit's model is archived                                                                                                                                             | Stop reporting for it.                                               |
| `429 rate_limited`     | Too many requests                                                                                                                                                        | Wait `Retry-After` seconds; spread daily reports over time.          |
| `5xx`, timeout         | Unknown outcome                                                                                                                                                          | Retry the same body with backoff; repeated measurements are skipped. |
