Zum Inhalt springen

Eine Waage über die Web Serial API im Browser auslesen

Veröffentlicht am

Ich wollte das Gewicht direkt im Browser haben, ohne Zusatzprogramm und ohne lokalen Dienst dazwischen. Die Waage ist eine GRAM Xtrem F1-30. Sie hängt per USB am Rechner, und der Browser spricht über die Web Serial API mit ihr.

Waage

Bis das lief, bin ich an vier Stellen hängen geblieben, die ich in keiner Anleitung gefunden habe. Um die geht es unten.

Die Hardware

Das Kabel

Die Xtrem hat eine normale RS-232-Schnittstelle. Heutige PCs haben keinen COM-Port mehr, also brauchst du einen USB-Seriell-Adapter. Beim Kauf solltest du auf Folgendes achten:

Eigenschaft Warum
Echter RS-232-Pegel (±12 V), nicht TTL mit 3,3 V oder 5 V Die Waage spricht klassisches RS-232. Die billigen „USB-TTL"-Platinen mit Stiftleiste passen nicht.
DB9-Stecker (male) am Adapter Passt auf die DB9-Buchse am Waagenkabel.
FTDI-Chip (Vendor-ID 0403) Läuft stabil, Treiber gibt es für alles. Prolific-Nachbauten machen gern Ärger.
USB-A oder USB-C Egal, Hauptsache FTDI und RS-232.

Im Browser meldet sich der Adapter mit der Vendor-ID 0403, ein FT232R zusätzlich mit der Produkt-ID 6001. Daran erkennt die Seite ihn später wieder, ohne dass jemand etwas auswählen muss.

Treiber

Unter Windows 11 reicht Einstecken, im Geräte-Manager steht dann gleich ein „USB Serial Port (COM3)". Unter Windows 10 hat mein Rechner den Adapter erst erkannt, nachdem ich den FTDI-VCP-Treiber (Virtual COM Port) installiert hatte.

Der Stromanschluss

An der Waage selbst gibt es keine Buchse für das Netzteil. Das war die erste Stelle, an der ich hängen geblieben bin. Die Xtrem hat ein Kombikabel, über das Daten und Strom laufen. Die Strombuchse sitzt am DB9-Stecker am anderen Ende, also da, wo auch der USB-Seriell-Adapter steckt. Das Netzteil (7,5 V, 1 A) gehört deshalb an den Kabelstecker und nicht an die Waage.

Kabel

Wenn die Waage läuft, leuchtet die grüne LED. Sie blinkt, solange Daten über die Schnittstelle gehen. So siehst du am schnellsten, ob überhaupt etwas passiert.

Die Waage einrichten

Mit WLAN-Modul macht die Xtrem ab Werk einen eigenen Hotspot auf. Die Werkseinstellungen stehen im Kommunikationsprotokoll des Herstellers:

Einstellung Standardwert
SSID GRAM_01
Passwort 12345678
IP der Waage 192.168.4.1
TCP-Server-Port 6666
UDP senden / empfangen 4444 / 5555

Eingestellt wird die Waage mit dem Windows-Programm GRAM-PC-XTREM. Du verbindest dich mit dem WLAN der Waage und startest das Programm.

GRAM-PC-XTREM mit der Gewichtsanzeige und der Werkzeugleiste, darin die Knöpfe für Nullstellen und Tara

Die Einstellungen sind mit einer PIN gesperrt. Ab Werk ist das 1009, genauso ein Standardwert wie das WLAN-Passwort oben.

Wichtig: Das PC-Programm belegt den COM-Port. Ein COM-Port kann nur von einem Programm gleichzeitig benutzt werden. Solange GRAM-PC-XTREM über USB verbunden ist, kommt der Browser also nicht dran. Schließ es vorher.

Die serielle Schnittstelle steht ab Werk auf 9600 Baud, 8 Datenbits, keine Parität, ein Stoppbit und kein Handshake. Das habe ich so gelassen.

Geht es auch ohne Kabel? Die Xtrem gibt das Gewicht auch über TCP heraus. Der „TCP server port (XTREM)" steht ab Werk auf 6666, und genau diesen Port sperren Chrome und Edge immer (ERR_UNSAFE_PORT, er steht als alter IRC-Port auf ihrer Blockliste). Stellst du ihn auf 80 um, erreichst du die Waage unter http://<IP-der-Waage>, ganz ohne Web Serial. Für einen festen Packplatz finde ich das Kabel trotzdem stabiler. Deshalb geht es ab hier um Web Serial.

Die Netzwerkeinstellungen der Waage als Access Point, TCP-Server-Port auf 80 gestellt

Das Protokoll in Kurzform

Jede Nachricht ist ein Rahmen:

STX  Absender(2)  Ziel(2)  Befehl(1)  Adresse(4)  Länge(2, hex)  Daten  LRC(2)  ETX  [CR LF]
0x02                                                                             0x03
  • Absender und Ziel sind Geräteadressen, FF heißt „an alle". Ich schicke alles von 00 an FF.
  • Befehl: R lesen, W schreiben, E ausführen. Antworten kommen mit Kleinbuchstaben zurück (r, w, e).
  • Die Adresse ist das Register.
  • LRC ist eine Prüfsumme. Das Beispiel des Herstellers schickt dort einfach 00, weil die Prüfung ab Werk ausgeschaltet ist (Register 0011h). Bei mir hat das so funktioniert.

Diese vier Befehle brauchst du:

Aktion Register Rahmen
Stream starten 1011 \x02 00FFE10110000 \x03\r\n
Stream stoppen 1010 \x02 00FFE10100000 \x03\r\n
Tara 0102 \x02 00FFE01020000 \x03\r\n
Nullstellen 0105 \x02 00FFE01050000 \x03\r\n

Am Beispiel Tara: 00 Absender, FF Ziel, E ausführen, 0102 Register, 00 Datenlänge, 00 LRC.

Die zweite Stelle, die mich Zeit gekostet hat: Die Waage sendet nichts von selbst. Erst nach dem Startbefehl 1011 schickt sie laufend die Gewichtsmeldung, ab Werk alle 50 ms.

Tara oder Null?

  • Null setzt den Nullpunkt neu. Das brauchst du, wenn die leere Waage ein paar Gramm anzeigt.
  • Tara übernimmt das Gewicht, das gerade draufliegt, zum Beispiel einen leeren Karton. Danach zeigt die Waage netto an. Das Brutto bleibt erhalten, und der Tara-Wert geht vom Wiegebereich ab.

Für „Tara löschen" gibt es noch 1103.

Die Gewichtsmeldung

Die Nutzdaten sind immer 26 Zeichen (1A hex) lang. Im Rahmen ohne STX und ETX sieht das so aus:

Pos.  0-1   2-3   4   5-8    9-10   11   12-19     20-21    22   23-30    31-32   33   34-36    37-38
      Abs.  Ziel  r   0107   1A     W    Brutto    Einheit  T    Tara     Einheit S    Status   LRC
  • Brutto und Tara haben je acht Zeichen, rechtsbündig mit Leerzeichen aufgefüllt und mit Dezimalpunkt, zum Beispiel " 230.3".
  • Die Einheit ist g , kg, oz oder lb.
  • Der Status besteht aus zwölf Bit, geschrieben als drei Hex-Zeichen.
Bit Wert Bedeutung
0 0x001 Null, Gewicht ungefähr 0
1 0x002 Tara aktiv
2 0x004 stabil, das Gewicht steht still
3 0x008 Netto-Anzeige
5 0x020 High-Resolution-Modus
7 0x080 Überlast
8 0x100 negatives Gewicht

Die dritte Stelle war das Vorzeichen. Es steht vor den acht Stellen des Werts, an Position 11 bzw. 22, wo sonst W und T stehen. Wer nur die acht Stellen liest, wie das Beispiel des Herstellers mit Substring(12, 8), verliert bei negativem Gewicht das Minus.

Läuft das in Firefox oder Safari?

Nein. Die Web Serial API gibt es nur in Chromium-Browsern wie Chrome, Edge, Opera und Brave. Außerdem nur in einem sicheren Kontext. Über reines http:// ist navigator.serial einfach undefined. Mozilla und Apple haben beide gesagt, dass sie die API nicht einbauen wollen, weil ihnen der direkte Zugriff einer Webseite auf serielle Geräte zu weit geht. Darauf zu warten, dass sich das ändert, lohnt sich also nicht.

Wer die Waage im Firefox braucht, kommt um ein lokales Programm nicht herum. Und genau das wollte ich hier vermeiden.

Der Code

Konstanten

JavaScript
const SERIAL_OPTIONS = {
    baudRate: 9600,
    dataBits: 8,
    stopBits: 1,
    parity: 'none',
    flowControl: 'none',
};

const XTREM_START = '\x0200FFE10110000\x03\r\n';
const XTREM_STOP = '\x0200FFE10100000\x03\r\n';
const XTREM_ZERO = '\x0200FFE01050000\x03\r\n';
const XTREM_TARE = '\x0200FFE01020000\x03\r\n';

Die Waage auswählen

Der Geräte-Dialog darf nur nach einem Klick aufgehen:

JavaScript
document.getElementById('chooseScale').addEventListener('click', async () => {
    try {
        const port = await navigator.serial.requestPort();
        await connect(port);
    } catch (err) {
        // NotFoundError = Dialog ohne Auswahl geschlossen, das ist kein Fehler
        if (err.name !== 'NotFoundError') console.error(err);
    }
});

Einmal freigegeben, merkt der Browser sich den Port, und beim nächsten Laden geht es ohne Dialog:

JavaScript
const ports = await navigator.serial.getPorts();
const scalePort = ports.find((p) => p.getInfo().usbVendorId === 0x0403);
if (scalePort) await connect(scalePort);

Mehr als Vendor- und Produkt-ID gibt Web Serial nicht heraus. Zwei gleiche Adapter am selben Rechner kannst du damit nicht auseinanderhalten.

Verbinden und Stream starten

JavaScript
let port = null;
let writing = Promise.resolve();

async function connect(selectedPort) {
    port = selectedPort;
    await port.open(SERIAL_OPTIONS);

    await send(XTREM_START);
    readLoop();
}

Senden, immer schön nacheinander

JavaScript
function send(command) {
    writing = writing.then(async () => {
        if (!port || !port.writable) return;

        const writer = port.writable.getWriter();
        try {
            await writer.write(new TextEncoder().encode(command));
        } catch (err) {
            // Gerät weg. Der Fehler darf die Kette nicht abreißen lassen
        } finally {
            writer.releaseLock();
        }
    });

    return writing;
}

Wozu die Kette über writing? Ein Stream hat nur einen Writer. Wenn zwei Schreibvorgänge gleichzeitig laufen, wirft getWriter() einen Fehler. Noch unangenehmer: Hängt ein Schreibvorgang noch, während port.close() läuft, schlägt das Schließen fehl. Der Port bleibt dann offen, und jeder weitere Verbindungsversuch scheitert, bis die Seite neu geladen wird. Das war die vierte Stelle, und die hat mich einen halben Tag gekostet.

Das catch gehört dazu. Scheitert ein Schreibvorgang ohne es, bleibt writing abgelehnt, und kein späterer Befehl wird mehr gesendet, auch der Stoppbefehl beim Trennen nicht.

Lesen: Rahmen aus dem Datenstrom schneiden

Ein read() liefert nicht zuverlässig genau einen Rahmen, mal ist es ein halber, mal sind es drei. Deshalb wird gepuffert und an STX und ETX zerschnitten:

JavaScript
let reader = null;

async function readLoop() {
    reader = port.readable.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    try {
        while (true) {
            const { value, done } = await reader.read();
            if (done) break;

            buffer += decoder.decode(value, { stream: true });

            while (true) {
                const start = buffer.indexOf('\x02');
                if (start === -1) {
                    buffer = '';
                    break;
                }
                if (start > 0) buffer = buffer.slice(start);

                const end = buffer.indexOf('\x03');
                if (end === -1) break; // Rahmen noch unvollständig

                const frame = buffer.slice(1, end);
                buffer = buffer.slice(end + 1); // CR/LF fliegt beim nächsten STX-Suchen raus

                const reading = parseFrame(frame);
                if (reading) showWeight(reading);
            }

            // Kein ETX in Sicht, etwa bei falscher Baudrate: nicht endlos puffern
            if (buffer.length > 1024) buffer = '';
        }
    } catch (err) {
        // Kabel gezogen oder Lesefehler: Hier endet nur die Schleife
        console.error(err);
    } finally {
        reader.releaseLock();
        reader = null;
    }
}

Die Meldung auswerten

JavaScript
function parseFrame(frame) {
    const target = frame.slice(2, 4);
    if (target !== '00' && target !== 'FF') return null;
    if (frame.slice(5, 9) !== '0107') return null;

    const length = parseInt(frame.slice(9, 11), 16);
    // Kopf(11) + Daten + LRC(2). Unter 26 Zeichen läse man die Prüfsumme als Status.
    if (
        !Number.isInteger(length) ||
        length < 26 ||
        frame.length !== length + 13
    )
        return null;

    const gross = parseValue(frame, 11);
    const tare = parseValue(frame, 22);
    const status = parseInt(frame.slice(34, 37), 16);
    if (gross === null || tare === null || !Number.isInteger(status))
        return null;

    const isNet = (status & 0x008) !== 0;

    return {
        weight: isNet ? gross - tare : gross,
        unit: frame.slice(20, 22).trim(),
        stable: (status & 0x004) !== 0,
        tared: (status & 0x002) !== 0,
    };
}

// Vorzeichen steht VOR den acht Stellen, nicht darin
function parseValue(frame, at) {
    const digits = frame.slice(at + 1, at + 9).trim();
    if (!/^\d+(?:\.\d+)?$/.test(digits)) return null;

    return frame[at] === '-' ? -Number(digits) : Number(digits);
}

Wenn du das Gewicht automatisch übernimmst, warte auf stable. Sonst erwischst du den Wert, während der Karton noch wackelt.

Tara und Null

JavaScript
document
    .getElementById('zero')
    .addEventListener('click', () => send(XTREM_ZERO));
document
    .getElementById('tare')
    .addEventListener('click', () => send(XTREM_TARE));

Sauber trennen

Schalte den Stream vor dem Schließen ab. Sonst sendet die Waage weiter ins Leere, und der Port ist beim nächsten Öffnen womöglich noch belegt:

JavaScript
async function disconnect() {
    if (reader) await reader.cancel().catch(() => {}); // beendet readLoop()

    // Nicht ewig warten, falls das Gerät schon weg ist
    await Promise.race([
        send(XTREM_STOP),
        new Promise((r) => setTimeout(r, 1000)),
    ]);

    try {
        await port.close();
    } catch (err) {
        /* Gerät schon abgezogen */
    }
    port = null;
}

window.addEventListener('pagehide', disconnect);

Was sonst noch schiefgeht

  • Der Schalter „Sealed": Solange er aktiv ist, antwortet die Xtrem über RS-232 auf gar nichts. Ich hatte erwartet, dass dann nur die internen Werte gesperrt sind. Bei meiner (ungeeichten) Waage stand er auf Sealed, und erst nach dem Ausschalten kamen Werte. Warum das so ist, weiß ich nicht. Ich vermute, dass die Firmware bei aktivem Siegel absichtlich nichts herausgibt, damit niemand ungeeichte Werte für geeichte hält.
  • „Failed to execute 'open' on 'SerialPort'": Der Port ist belegt. Entweder läuft noch GRAM-PC-XTREM, oder ein anderer Tab hat die Waage offen, oder die Seite wurde gerade neu geladen und das alte Dokument hält den Port noch ein paar Millisekunden. Im letzten Fall hilft ein zweiter Versuch nach 500 ms. Weitere Versuche dann mit größerem Abstand.
  • Es kommen keine Werte: Die Waage war beim Start noch aus, oder das Kabel wurde neu gesteckt. Dann hat sie den Startbefehl nie bekommen. Schick XTREM_START jede Sekunde neu, solange nichts ankommt. Das Beispiel des Herstellers macht es genauso.
  • Kabel gezogen: reader.read() wirft einen Fehler oder liefert done. Mit navigator.serial.addEventListener("connect", …) merkst du, wenn die Waage wieder da ist.
  • Tab im Hintergrund: Trenn bei visibilitychange die Verbindung, damit ein anderer Tab die Waage bekommen kann.
  • Nur Müll im Puffer: Meistens stimmt die Baudrate nicht. Ab Werk ist sie 9600.
  • Nichts passiert, die grüne LED ist aus: Das Netzteil steckt nicht. Die Buchse sitzt am Stecker des seriellen Kabels, nicht an der Waage.

Fazit

Die Industriewaage läuft jetzt im Browser, ohne zusätzliche Software, mit ein paar hundert Zeilen JavaScript. Das Protokoll selbst war schnell verstanden. Aufgehalten haben mich vier Kleinigkeiten:

  1. Die Strombuchse sitzt am Kabelstecker.
  2. Die Waage sendet erst nach einem Startbefehl.
  3. Das Vorzeichen steht vor dem Messwert.
  4. Schreibvorgänge müssen nacheinander laufen, sonst bleibt der Port hängen.

Genannte Produkt-, Firmen- und Markennamen gehören ihren jeweiligen Inhabern und werden hier allein beschreibend verwendet. Eine Verbindung zu ihnen oder eine Empfehlung durch sie besteht nicht.