# QR-Etiketten für eine Produktionscharge

> Listen Sie die veröffentlichten Einheiten einer Charge über das Seriennummernpräfix und speichern Sie den QR-Code jeder Einheit als SVG-Datei für den Etikettendruck.

Das Skript listet mit [`listBatteries`](https://passport.kabin.sh/docs/de/api-reference/catalog/list-batteries) die Einheiten, deren
Seriennummer mit einem Präfix beginnt (Ihre Charge, etwa `SV2027-0001`) und deren Pass veröffentlicht ist, und
speichert den QR-Code jeder Einheit mit [`getPassportQr`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport-qr) als
`labels/<Seriennummer>.svg`. Es braucht einen Schlüssel mit dem Scope `read`.

- Andere Wege, eine Charge auszuwählen: `import_id` (die Einheiten eines Tabellenimports) oder `model_version_id`.
  Ersetzen Sie dafür `serial_prefix` in der Abfrage.
- Etiketten, die vor der Veröffentlichung gedruckt werden: Verwenden Sie stattdessen `passport_status=qr_locked`.
  Den QR-Code gibt es, sobald die Kennungen gesperrt sind, und der Link darin ändert sich danach nie mehr.
- SVG skaliert für jede Etikettengröße. Für eine Rastergrafik fragen Sie `?format=png&size=1024` an (128 bis 2048
  Pixel).
- Ein `/` in einer Seriennummer wird im Dateinamen zu `_`.

```bash title="cURL"
#!/usr/bin/env bash
# qr-labels.sh SV2027-0001: an SVG QR code per published unit of a batch (needs curl and jq)
set -euo pipefail
prefix="$1" # the batch: serial numbers that start with this, e.g. SV2027-0001
auth="Authorization: Bearer $BATTERYPASS_API_KEY"
mkdir -p labels
cursor=""
while :; do
  page=$(curl -sS --fail --retry 5 -G "$BATTERYPASS_URL/api/v1/batteries" -H "$auth" \
    --data-urlencode "serial_prefix=$prefix" -d passport_status=published -d limit=200 \
    ${cursor:+--data-urlencode "cursor=$cursor"})
  jq -r '.items[] | [.id, (.serial_number | gsub("/"; "_"))] | @tsv' <<<"$page" |
    while IFS=$'\t' read -r id serial; do
      curl -sS --fail --retry 5 -H "$auth" -o "labels/$serial.svg" \
        "$BATTERYPASS_URL/api/v1/batteries/$id/passport/qr?format=svg"
      echo "labels/$serial.svg"
    done
  cursor=$(jq -r '.next_cursor // empty' <<<"$page")
  [ -n "$cursor" ] || break
done
```

```ts title="TypeScript"
// qr-labels.mts: npx tsx qr-labels.mts SV2027-0001
import { mkdir, writeFile } from 'node:fs/promises';

const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` };
const prefix = process.argv[2]; // the batch: serial numbers that start with this, e.g. SV2027-0001
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function get(path: string): Promise<Response> {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(BASE + path, { headers });
    if (res.ok) return res;
    if (res.status !== 429 || attempt === 5) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`);
    await sleep(Number(res.headers.get('Retry-After') ?? 1) * 1000);
  }
}

await mkdir('labels', { recursive: true });
let cursor: string | null = null;
do {
  const query = new URLSearchParams({ serial_prefix: prefix, passport_status: 'published', limit: '200' });
  if (cursor) query.set('cursor', cursor);
  const page = await (await get(`/batteries?${query}`)).json();
  for (const unit of page.items) {
    const svg = await (await get(`/batteries/${unit.id}/passport/qr?format=svg`)).arrayBuffer();
    const file = `labels/${unit.serial_number.replaceAll('/', '_')}.svg`;
    await writeFile(file, Buffer.from(svg));
    console.log(file);
  }
  cursor = page.next_cursor;
} while (cursor);
```

```python title="Python"
# qr_labels.py: python qr_labels.py SV2027-0001
import os
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"]
prefix = sys.argv[1]  # the batch: serial numbers that start with this, e.g. SV2027-0001


def get(path, **params):
    for attempt in range(1, 6):
        res = session.get(BASE + path, params=params, timeout=30)
        if res.status_code != 429 or attempt == 5:
            res.raise_for_status()
            return res
        time.sleep(int(res.headers.get("Retry-After", "1")))


os.makedirs("labels", exist_ok=True)
cursor = None
while True:
    params = {"serial_prefix": prefix, "passport_status": "published", "limit": 200}
    if cursor:
        params["cursor"] = cursor
    page = get("/batteries", **params).json()
    for unit in page["items"]:
        svg = get(f"/batteries/{unit['id']}/passport/qr", format="svg").content
        path = os.path.join("labels", unit["serial_number"].replace("/", "_") + ".svg")
        with open(path, "wb") as f:
            f.write(svg)
        print(path)
    cursor = page["next_cursor"]
    if not cursor:
        break
```

```go title="Go"
// qr-labels: go run . SV2027-0001
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

var (
	base = os.Getenv("BATTERYPASS_URL") + "/api/v1"
	key  = os.Getenv("BATTERYPASS_API_KEY")
)

// get returns the response body of a GET request and waits out 429 answers.
func get(path string) ([]byte, error) {
	for attempt := 1; ; attempt++ {
		req, err := http.NewRequest(http.MethodGet, base+path, nil)
		if err != nil {
			return nil, err
		}
		req.Header.Set("Authorization", "Bearer "+key)
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, err
		}
		body, err := io.ReadAll(res.Body)
		res.Body.Close()
		switch {
		case err != nil:
			return nil, err
		case res.StatusCode == http.StatusOK:
			return body, nil
		case res.StatusCode != http.StatusTooManyRequests || attempt == 5:
			return nil, fmt.Errorf("%s: %s %s", path, res.Status, body)
		}
		wait, _ := strconv.Atoi(res.Header.Get("Retry-After"))
		time.Sleep(time.Duration(max(wait, 1)) * time.Second)
	}
}

func main() {
	prefix := os.Args[1] // the batch: serial numbers that start with this, e.g. SV2027-0001
	if err := os.MkdirAll("labels", 0o755); err != nil {
		log.Fatal(err)
	}
	cursor := ""
	for {
		q := url.Values{"serial_prefix": {prefix}, "passport_status": {"published"}, "limit": {"200"}}
		if cursor != "" {
			q.Set("cursor", cursor)
		}
		data, err := get("/batteries?" + q.Encode())
		if err != nil {
			log.Fatal(err)
		}
		var page struct {
			Items []struct {
				ID           string `json:"id"`
				SerialNumber string `json:"serial_number"`
			} `json:"items"`
			NextCursor *string `json:"next_cursor"`
		}
		if err := json.Unmarshal(data, &page); err != nil {
			log.Fatal(err)
		}
		for _, unit := range page.Items {
			svg, err := get("/batteries/" + unit.ID + "/passport/qr?format=svg")
			if err != nil {
				log.Fatal(err)
			}
			file := filepath.Join("labels", strings.ReplaceAll(unit.SerialNumber, "/", "_")+".svg")
			if err := os.WriteFile(file, svg, 0o644); err != nil {
				log.Fatal(err)
			}
			fmt.Println(file)
		}
		if page.NextCursor == nil {
			break
		}
		cursor = *page.NextCursor
	}
}
```

```text title="Ausgabe"
labels/SV2027-000182.svg
labels/SV2027-000184.svg
labels/SV2027-000185.svg
```

Jede Einheit kostet eine Anfrage, und alle Schlüssel Ihrer Organisation teilen sich 600 Anfragen pro Minute; bei
`429` warten die Skripte. Mehr zum QR-Code und zu den anderen Exporten unter
[Pässe exportieren](https://passport.kabin.sh/docs/de/guides/export-passports).
