# Nächtlicher Zustandsdaten-Push

> Ein geplanter Job, der die Messwerte der letzten Nacht aus Ihrer eigenen Quelle liest und je Einheit ein updateCondition sendet, mit Backoff bei 429.

Der Job liest Messwerte aus Ihrer eigenen Quelle, hier ein Platzhalter, und sendet je Einheit ein
[`updateCondition`](https://passport.kabin.sh/docs/de/api-reference/lifecycle/update-condition). Er braucht einen Schlüssel mit dem Scope
`condition`. Starten Sie ihn einmal pro Nacht per Cron, systemd-Timer oder Ihrem Scheduler.

- Bei `429` oder `5xx` wartet er (`Retry-After`, sonst exponentieller Backoff) und sendet denselben Body erneut.
  Das ist unbedenklich: Bereits gespeicherte Werte kommen in `skipped` als `unchanged` zurück.
- Andere Fehler (etwa `400 validation_error` für einen Wert außerhalb des Bereichs) protokolliert er je Einheit,
  macht mit der nächsten weiter und endet am Schluss mit dem Status 1.
- Tragen Sie in `measured_at` den Zeitpunkt der Messung ein, nicht den Zeitpunkt, zu dem der Job läuft.

```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="Ausgabe"
0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f accepted 1, skipped 1, published
```

Die Shell-Fassung liest `readings.jsonl`, je Einheit ein JSON-Objekt wie in ihrem Kopf gezeigt. Felder,
Maßeinheiten und die Regeln für `measured_at` erklärt [Zustandsdaten senden](https://passport.kabin.sh/docs/de/guides/push-condition-data).
Denken Sie bei vielen Einheiten daran, dass sich alle Schlüssel Ihrer Organisation 600 Anfragen pro Minute teilen.
