Zum Inhalt

Ein Inventarprogramm anbinden

Eine Anleitung für die Entwickler eines Programms, das über den Druckserver Etiketten druckt, hier ein Inventarprogramm, das Inventaretiketten druckt: ein Etikett, eine Serie von Etiketten in einer Anfrage, den Auftrag verfolgen, nach einem Papierstau weiterdrucken und Fehler behandeln. Jede Anfrage ist in der API-Referenz beschrieben, die Einrichtung des Servers in der Serverinstallation.

Die Ausgangslage

Die Beispiele verwenden diese Namen; Ihre werden anders lauten:

  • Der Server: https://labels.example.com/api/v1, erreichbar über den Reverse Proxy der Firma.
  • Das Etikett asset-tag.tavletta im Etikettenordner, gestaltet in der Tavletta-App, mit einem QR-Code und einem Text, die beide den Platzhalter {AssetNumber} enthalten.
  • Der Drucker it-cab aus printers.toml: ein cab-Etikettendrucker mit Thermotransfer (method = "transfer"), der um 180 Grad gedreht druckt (upside_down = true).
  • Ein Zugangstoken mit der Rolle print, beschränkt auf it-cab, erstellt vom Administrator des Servers:
tavletta token new inventory-tool --role print --printer it-cab \
    --tokens tokens.toml --printers printers.toml

Bewahren Sie das Token im Geheimnisspeicher des Programms auf (verschlüsselte Konfiguration, ein Vault), nicht im Code. In den Shell-Beispielen:

TAVLETTA=https://labels.example.com/api/v1
TOKEN=tav_3f9c0a1b2c4d_Xq2…

1. Was das Etikett braucht

curl -H "Authorization: Bearer $TOKEN" "$TAVLETTA/labels/asset-tag"

Die Antwort nennt die Spalten des Etiketts, columns (hier ["AssetNumber"]): Jede Datenzeile braucht diese Schlüssel. Ein Platzhalter ohne Wert in der Zeile lässt den Druck mit 422 label_unprintable ("No such column") abweisen, so druckt ein Etikett nie einen halb leeren Text.

2. Vorschau

Eine Vorschau druckt nichts und liefert das Etikett als PNG-Bild, zum Beispiel um es vor dem Drucken im Programm zu zeigen:

curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"label": "asset-tag", "printer": "it-cab", "data_rows": [{"AssetNumber": "100001"}]}' \
     -o preview.png "$TAVLETTA/preview"
  • Mit "format": "json" ist die Antwort JSON mit dem Bild in Base64 und den Warnungen, warnings (zum Beispiel font_missing, barcode_narrow).
  • "view": "printer" zeigt das Bild so, wie der Drucker es bekommt (gedreht).
  • Eine Vorschau braucht nur die Rolle preview.

3. Ein Etikett drucken

curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"label": "asset-tag", "printer": "it-cab", "data_rows": [{"AssetNumber": "100001"}], "wait": 30}' \
     "$TAVLETTA/print"
  • Der Server prüft die Anfrage, reiht einen Auftrag für den Drucker ein und antwortet mit dem Auftrag. Mit "wait": 30 wartet er bis zu 30 Sekunden auf den Auftrag: 200 mit "state": "done", sobald gedruckt, sonst 202 mit dem Auftrag noch queued oder printing.
  • Der Header Location der Antwort und die id des Auftrags führen zu GET /api/v1/jobs/{id}.
  • Jeder Drucker druckt einen Auftrag nach dem anderen; Aufträge verschiedener Drucker laufen nebeneinander.

4. Eine Serie in einer Anfrage

Zwanzig Inventaretiketten sind eine Anfrage mit zwanzig Datenzeilen, nicht zwanzig Anfragen: Sie werden als ein Auftrag der Reihe nach gedruckt, und ein Problem hält den Auftrag bei einer bekannten Zeile an.

curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"label": "asset-tag", "printer": "it-cab", "wait": 60,
          "data_rows": [{"AssetNumber": "100001"}, {"AssetNumber": "100002"},
                        {"AssetNumber": "100003"}, {"AssetNumber": "100020"}]}' \
     "$TAVLETTA/print"

Das empfohlene Vorgehen: Das Programm verwaltet die Nummern. Das Programm nimmt die nächsten freien Inventarnummern aus seiner eigenen Datenbank, speichert sie bei den Geräten und schickt sie als Datenzeilen. Das Etikett zählt nie selbst, so können ein zweiter Server, eine zurückgespielte Sicherung oder ein Nachdruck nie eine Nummer doppelt vergeben.

Die Alternative: der Zähler des Etiketts

Ein Etikett mit der Variablen {=counter} (zum Beispiel {=counter:000000} für sechs Stellen) zählt selbst. Das Programm kann trotzdem sagen, wo es beginnt:

curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"label": "asset-tag-counter", "printer": "it-cab",
          "counter_start": 100001, "copies": 20, "no_save_counter": true}' \
     "$TAVLETTA/print"
  • counter_start ist die erste Nummer; der Zähler steigt mit jedem Etikett und jeder Kopie: 100001 bis 100020.
  • no_save_counter: Der Server speichert die nächste Nummer nicht in der Etikettendatei. Ohne diese Angabe behält die Etikettendatei die nächste Nummer (100021), und der nächste Druck ohne counter_start macht dort weiter.
  • Verwenden Sie dies nur, wenn die Nummern nicht schon vor dem Drucken in der Datenbank des Programms stehen müssen.

5. Den Auftrag verfolgen

curl -H "Authorization: Bearer $TOKEN" "$TAVLETTA/jobs/$JOB"
state Bedeutung
queued wartet auf den Drucker
waiting wartet auf die Etikettendatei (ein anderer Auftrag speichert seinen Zähler)
printing druckt; printed von total Etiketten sind draussen
done alle Etiketten gedruckt
failed angehalten: error sagt warum, rows_printed wie weit er kam
canceled abgebrochen
  • Fragen Sie alle ein bis zwei Sekunden nach, solange der Zustand nicht endgültig ist; der Server behält abgeschlossene Aufträge 24 Stunden lang.
  • Ein print-Token sieht nur seine eigenen Aufträge; der Auftrag eines anderen Tokens ist "nicht gefunden".

6. Nach einem Stau: den Rest drucken

Gehen dem Drucker die Etiketten aus oder staut er, schlägt der Auftrag fehl, zum Beispiel:

{"state": "failed", "printed": 13, "total": 20, "rows_printed": 13,
 "error": {"code": "printer_out_of_labels",
           "message": "… Printing stopped at row 14. To continue, print again from row 14."}}

Sobald der Drucker bereit ist, schicken Sie dieselbe Anfrage noch einmal mit rows, den noch fehlenden Zeilen:

curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"label": "asset-tag", "printer": "it-cab", "rows": "14-20",
          "data_rows": [{"AssetNumber": "100001"}, {"AssetNumber": "100020"}]}' \
     "$TAVLETTA/print"

rows zählt die Datenzeilen ab 1: "14-20", "14-" (bis zur letzten), "5". Schicken Sie dieselben Datenzeilen wie zuvor, damit Zeile 14 dasselbe Gerät ist. Ein Etikett, das halb gedruckt war, als der Drucker anhielt, gehört zur fehlgeschlagenen Zeile und wird noch einmal gedruckt.

7. Abbrechen

curl -X DELETE -H "Authorization: Bearer $TOKEN" "$TAVLETTA/jobs/$JOB"

Ein wartender Auftrag wird sofort abgebrochen; ein druckender hält nach dem Etikett an, das gerade gedruckt wird. 409 job_finished, wenn er schon fertig war.

8. Wiederholen und Fehler

POST /print ist nicht idempotent: Eine zweimal geschickte Anfrage druckt zweimal. Deshalb:

  • Automatisch wiederholen nur Antworten, die abgewiesen wurden, bevor ein Auftrag entstand: 503 busy und 429 queue_full, nach einer wachsenden Pause (1, 2, 4, 8 s).
  • Nicht automatisch wiederholen, wenn die Verbindung abbrach oder in ein Timeout lief, nachdem die Anfrage geschickt war: Der Auftrag kann existieren und drucken. Zeigen Sie der Bedienperson, was geschehen ist, lassen Sie sie den Drucker prüfen und drucken Sie dann die fehlenden Etiketten mit rows.
  • Die Anfrage korrigieren bei anderen 4xx-Antworten; der Code sagt, was falsch ist:
Code Was das Programm tut
unauthorized (401) Token falsch, abgelaufen oder widerrufen: beim Administrator ein neues verlangen.
forbidden (403) Die Rolle des Tokens reicht nicht.
printer_not_found, label_not_found (404) Name falsch geschrieben, oder das Token darf diesen Drucker nicht verwenden.
invalid_field, unknown_field (400) Ein Fehler im Programm: Die Meldung nennt das Feld.
too_many_copies (400), too_many_labels (413) Die Serie auf mehrere Aufträge verteilen.
label_unprintable (422) Die Daten passen nicht zum Etikett (eine fehlende Spalte, ein Code, der den Wert nicht fassen kann): die Meldung zeigen.
busy (503), queue_full (429) Nach einer Pause wiederholen.
printer_<code> in einem fehlgeschlagenen Auftrag Der Bedienperson die Meldung zeigen; nach der Behebung mit rows weitermachen.

Die Meldung (error.message) ist in der Sprache von Accept-Language und für Menschen gedacht: Zeigen Sie sie, aber entscheiden Sie nach dem Code.

Beispiel: Python

Mit dem Paket requests:

import os
import time

import requests

BASE = os.environ.get("TAVLETTA", "https://labels.example.com/api/v1")
TOKEN = os.environ["TAVLETTA_TOKEN"]  # from the secret store, never in the code
FINAL = {"done", "failed", "canceled"}


class LabelError(Exception):
    def __init__(self, status, code, message):
        super().__init__("%s %s: %s" % (status, code, message))
        self.status, self.code = status, code


session = requests.Session()
session.headers.update({"Authorization": "Bearer " + TOKEN, "Accept-Language": "de-CH"})


def call(method, path, **kwargs):
    response = session.request(method, BASE + path, timeout=330, **kwargs)
    if response.status_code >= 400:
        try:
            error = response.json()["error"]
        except (ValueError, KeyError):  # not from Tavletta, e.g. the proxy
            error = {"code": "http_%d" % response.status_code, "message": response.text}
        raise LabelError(response.status_code, error["code"], error["message"])
    return response


def print_assets(numbers, printer="it-cab", rows=None):
    """One label per asset number, in one job; returns the finished job."""
    body = {
        "label": "asset-tag",
        "printer": printer,
        "data_rows": [{"AssetNumber": str(n)} for n in numbers],
        "wait": 30,
    }
    if rows:
        body["rows"] = rows  # e.g. "14-20": only these rows
    for attempt in range(5):
        try:
            job = call("POST", "/print", json=body).json()
            break
        except LabelError as e:
            # refused before it was queued: nothing printed, safe to retry
            if e.code not in ("busy", "queue_full") or attempt == 4:
                raise
            time.sleep(2**attempt)
    while job["state"] not in FINAL:  # still printing after "wait"
        time.sleep(2)
        job = call("GET", "/jobs/" + job["id"]).json()
    return job


def preview(number, path="preview.png"):
    body = {"label": "asset-tag", "data_rows": [{"AssetNumber": str(number)}]}
    with open(path, "wb") as file:
        file.write(call("POST", "/preview", json=body).content)


if __name__ == "__main__":
    preview(100001)
    numbers = range(100001, 100021)  # 20 labels
    job = print_assets(numbers)
    if job["state"] == "failed":
        print("Stopped:", job["error"]["message"])
        # rows_printed rows are done: print the rest once the printer is fixed
        rest = "%d-%d" % (job["rows_printed"] + 1, len(numbers))
        print("To continue:", rest)
    else:
        print(job["state"], job["printed"], "of", job["total"])

Beispiel: C# und .NET

Mit HttpClient und System.Net.Http.Json (.NET 8 oder neuer), als Konsolenprogramm; in einer Anwendung registrieren Sie einen HttpClient (zum Beispiel mit IHttpClientFactory) und verwenden ihn wieder.

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

var baseUrl = Environment.GetEnvironmentVariable("TAVLETTA") ?? "https://labels.example.com/api/v1";
var token = Environment.GetEnvironmentVariable("TAVLETTA_TOKEN")!; // from the secret store

using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(330) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
http.DefaultRequestHeaders.AcceptLanguage.ParseAdd("de-CH");
var labels = new LabelClient(http);

var numbers = Enumerable.Range(100001, 20).Select(n => n.ToString()).ToList();
var job = await labels.PrintAssetsAsync(numbers, "it-cab");
Console.WriteLine(job.State == "failed"
    ? $"Stopped: {job.Error?.Message} To continue: rows {job.RowsPrinted + 1}-{numbers.Count}"
    : $"{job.State}: {job.Printed} of {job.Total}");

public record ApiError(string Code, string Message);
public record ErrorBody(ApiError Error);
public record Job(
    string Id, string State, int Printed, int Total,
    [property: JsonPropertyName("rows_printed")] int RowsPrinted, ApiError? Error);

public class LabelException(HttpStatusCode status, string code, string message) : Exception(message)
{
    public HttpStatusCode Status { get; } = status;
    public string Code { get; } = code;
}

public class LabelClient(HttpClient http)
{
    static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    public async Task<Job> PrintAssetsAsync(IList<string> assetNumbers, string printer, string? rows = null)
    {
        var body = new Dictionary<string, object>
        {
            ["label"] = "asset-tag",
            ["printer"] = printer,
            ["data_rows"] = assetNumbers.Select(n => new Dictionary<string, string> { ["AssetNumber"] = n }),
            ["wait"] = 30,
        };
        if (rows != null) body["rows"] = rows; // e.g. "14-20"
        Job job;
        for (var attempt = 0; ; attempt++)
        {
            try { job = await SendAsync<Job>(HttpMethod.Post, "print", body); break; }
            catch (LabelException e) when (e.Code is "busy" or "queue_full" && attempt < 4)
            {
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))); // nothing was printed
            }
        }
        while (job.State is "queued" or "waiting" or "printing")
        {
            await Task.Delay(2000);
            job = await SendAsync<Job>(HttpMethod.Get, $"jobs/{job.Id}");
        }
        return job;
    }

    public Task<Job> CancelAsync(string jobId) => SendAsync<Job>(HttpMethod.Delete, $"jobs/{jobId}");

    async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null)
    {
        using var request = new HttpRequestMessage(method, path);
        // as a string: the server needs a Content-Length (JsonContent would send it chunked)
        if (body != null)
            request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        using var response = await http.SendAsync(request);
        if (!response.IsSuccessStatusCode)
        {
            ErrorBody? error = null;
            try { error = await response.Content.ReadFromJsonAsync<ErrorBody>(Json); } catch (JsonException) { }
            throw new LabelException(response.StatusCode,
                error?.Error.Code ?? $"http_{(int)response.StatusCode}",
                error?.Error.Message ?? response.ReasonPhrase ?? "");
        }
        return (await response.Content.ReadFromJsonAsync<T>(Json))!;
    }
}
  • Die Basisadresse endet mit /, und die Pfade beginnen ohne ("print", "jobs/…"): So behält HttpClient das /api/v1.
  • Der Inhalt geht als StringContent: Der Server braucht eine Content-Length, und JsonContent.Create würde den Inhalt in Stücken ohne eine solche schicken (411 length_required).