# Nightly condition push

> A scheduled job that reads last night's measurements from your own source and sends one updateCondition per unit, with backoff on 429.

The job reads measurements from your own source, a placeholder here, and sends one
[`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition) per unit. It needs a key with the scope
`condition`. Run it once a night with cron, a systemd timer or your scheduler.

- On `429` or `5xx` it waits (`Retry-After`, otherwise exponential backoff) and sends the same body again. That is
  safe: values that are already stored come back in `skipped` as `unchanged`.
- Other errors (for example `400 validation_error` for a value out of range) are logged per unit, the job carries
  on with the next one and exits with status 1 at the end.
- Put the time of the measurement into `measured_at`, not the time the job runs.

```bash title="cURL"
#!/usr/bin/env bash
# nightly-condition-push.sh: one updateCondition per line of readings.jsonl (needs curl and jq)
# Each line, written by your BMS export: {"battery_id": "…", "measured_at": "…", "values": {"condition.…": 81.5}}
set -uo pipefail
failed=0
while IFS= read -r line; do
  id=$(jq -r .battery_id <<<"$line")
  body=$(jq -c '{measured_at, note: "Nightly BMS export",
    values: (.values | to_entries | map({field_key: .key, value: .value}))}' <<<"$line")
  # --retry repeats 429 and 5xx answers and waits as long as Retry-After says
  status=$(curl -sS --retry 5 -o response.json -w '%{http_code}' -X PATCH \
    "$BATTERYPASS_URL/api/v1/batteries/$id/condition" \
    -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
    -H "Content-Type: application/json" \
    --data "$body")
  if [ "$status" = 200 ]; then
    jq -c --arg id "$id" '{id: $id, accepted: (.accepted | length), skipped, outcome: .publication.outcome}' response.json
  else
    failed=1
    jq -c --arg id "$id" --arg status "$status" '{id: $id, status: $status, code, request_id}' response.json >&2
  fi
done < readings.jsonl
exit $failed
```

```ts title="TypeScript"
// nightly-condition-push.mts: npx tsx nightly-condition-push.mts
const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const KEY = process.env.BATTERYPASS_API_KEY; // a key with the scope "condition"
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

type Reading = { batteryId: string; measuredAt: string; values: Record<string, unknown> };

// Placeholder: read last night's measurements from your BMS, historian or database.
async function readMeasurements(): Promise<Reading[]> {
  return [
    {
      batteryId: '0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f',
      measuredAt: '2026-10-24T02:00:00Z',
      values: { 'condition.state_of_charge': 81.5, 'condition.number_of_full_cycles': 412 },
    },
  ];
}

async function push(r: Reading): Promise<string> {
  const body = JSON.stringify({
    measured_at: r.measuredAt,
    note: 'Nightly BMS export',
    values: Object.entries(r.values).map(([field_key, value]) => ({ field_key, value })),
  });
  for (let attempt = 1; attempt <= 6; attempt++) {
    const res = await fetch(`${BASE}/batteries/${r.batteryId}/condition`, {
      method: 'PATCH',
      headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
      body,
    });
    if (res.status === 429 || res.status >= 500) {
      // Sending the same readings again is safe: values already stored come back as skipped.
      const retryAfter = Number(res.headers.get('Retry-After'));
      await sleep(retryAfter > 0 ? retryAfter * 1000 : Math.random() * 1000 * 2 ** attempt);
      continue;
    }
    const result = await res.json();
    if (!res.ok) throw new Error(`${res.status} ${result.code} (request_id ${result.request_id})`);
    return `accepted ${result.accepted.length}, skipped ${result.skipped.length}, ${result.publication.outcome}`;
  }
  throw new Error('gave up after 6 attempts');
}

let failed = 0;
for (const reading of await readMeasurements()) {
  try {
    console.log(reading.batteryId, await push(reading));
  } catch (err) {
    failed++;
    console.error(reading.batteryId, String(err));
  }
}
process.exitCode = failed > 0 ? 1 : 0;
```

```python title="Python"
# nightly_condition_push.py
import os
import random
import sys
import time

import requests

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


def read_measurements():
    """Placeholder: read last night's measurements from your BMS, historian or database."""
    return [
        {
            "battery_id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f",
            "measured_at": "2026-10-24T02:00:00Z",
            "values": {"condition.state_of_charge": 81.5, "condition.number_of_full_cycles": 412},
        },
    ]


def push(reading):
    body = {
        "measured_at": reading["measured_at"],
        "note": "Nightly BMS export",
        "values": [{"field_key": k, "value": v} for k, v in reading["values"].items()],
    }
    for attempt in range(1, 7):
        res = session.patch(f"{BASE}/batteries/{reading['battery_id']}/condition", json=body, timeout=30)
        if res.status_code == 429 or res.status_code >= 500:
            # Sending the same readings again is safe: values already stored come back as skipped.
            retry_after = res.headers.get("Retry-After")
            time.sleep(int(retry_after) if retry_after else random.uniform(0, 2**attempt))
            continue
        result = res.json()
        if not res.ok:
            raise RuntimeError(f"{res.status_code} {result['code']} (request_id {result.get('request_id')})")
        return f"accepted {len(result['accepted'])}, skipped {len(result['skipped'])}, {result['publication']['outcome']}"
    raise RuntimeError("gave up after 6 attempts")


failed = 0
for reading in read_measurements():
    try:
        print(reading["battery_id"], push(reading))
    except Exception as err:
        failed += 1
        print(reading["battery_id"], err, file=sys.stderr)
sys.exit(1 if failed else 0)
```

```go title="Go"
// nightly-condition-push: go run .
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"math/rand/v2"
	"net/http"
	"os"
	"strconv"
	"time"
)

var (
	base = os.Getenv("BATTERYPASS_URL") + "/api/v1"
	key  = os.Getenv("BATTERYPASS_API_KEY") // a key with the scope "condition"
)

type reading struct {
	BatteryID  string
	MeasuredAt string
	Values     map[string]any
}

// readMeasurements is a placeholder: read last night's measurements from your BMS, historian or database.
func readMeasurements() []reading {
	return []reading{{
		BatteryID:  "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f",
		MeasuredAt: "2026-10-24T02:00:00Z",
		Values:     map[string]any{"condition.state_of_charge": 81.5, "condition.number_of_full_cycles": 412},
	}}
}

type result struct {
	Accepted    []json.RawMessage `json:"accepted"`
	Skipped     []json.RawMessage `json:"skipped"`
	Publication struct {
		Outcome string `json:"outcome"`
	} `json:"publication"`
	Code      string `json:"code"`
	RequestID string `json:"request_id"`
}

func push(r reading) (string, error) {
	values := []map[string]any{}
	for k, v := range r.Values {
		values = append(values, map[string]any{"field_key": k, "value": v})
	}
	body, err := json.Marshal(map[string]any{"measured_at": r.MeasuredAt, "note": "Nightly BMS export", "values": values})
	if err != nil {
		return "", err
	}
	for attempt := 1; attempt <= 6; attempt++ {
		req, err := http.NewRequest(http.MethodPatch, base+"/batteries/"+r.BatteryID+"/condition", bytes.NewReader(body))
		if err != nil {
			return "", err
		}
		req.Header.Set("Authorization", "Bearer "+key)
		req.Header.Set("Content-Type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return "", err
		}
		var out result
		_ = json.NewDecoder(res.Body).Decode(&out)
		res.Body.Close()
		if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 {
			// Sending the same readings again is safe: values already stored come back as skipped.
			wait := time.Duration(rand.Int64N(int64(time.Second << attempt)))
			if s, _ := strconv.Atoi(res.Header.Get("Retry-After")); s > 0 {
				wait = time.Duration(s) * time.Second
			}
			time.Sleep(wait)
			continue
		}
		if res.StatusCode != http.StatusOK {
			return "", fmt.Errorf("%d %s (request_id %s)", res.StatusCode, out.Code, out.RequestID)
		}
		return fmt.Sprintf("accepted %d, skipped %d, %s", len(out.Accepted), len(out.Skipped), out.Publication.Outcome), nil
	}
	return "", errors.New("gave up after 6 attempts")
}

func main() {
	failed := 0
	for _, r := range readMeasurements() {
		summary, err := push(r)
		if err != nil {
			failed++
			log.Println(r.BatteryID, err)
			continue
		}
		fmt.Println(r.BatteryID, summary)
	}
	if failed > 0 {
		os.Exit(1)
	}
}
```

```text title="Output"
0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f accepted 1, skipped 1, published
```

The shell version reads `readings.jsonl`, one JSON object per unit as shown in its header. Fields, units of
measure and the rules for `measured_at` are explained in
[Push condition data](https://passport.kabin.sh/docs/en/guides/push-condition-data). With many units, keep in mind that all keys of your
organisation share 600 requests per minute.
