Zum Inhalt springen

Pelletheizung Fröling P4 über COM1 auslesen

Veröffentlicht am

Im Keller steht ein Fröling P4 Pellet mit der Steuerung Lambdatronic P 3200. Kessel-, Puffer- und Außentemperatur, Füllstand und Fehler will ich nicht nur am Display im Keller sehen. Ein Raspberry Pi fragt die Werte deshalb alle fünf Minuten über die serielle Service-Schnittstelle ab.

Das Protokoll ist nicht offiziell dokumentiert. Hier steht, wie es aufgebaut ist und wo die Fallen liegen.

Fröling P4 Pellet: roter Pelletkessel mit grauem Deckel und Display oben

COM1 oder COM2?

Die Lambdatronic hat zwei Schnittstellen:

Schnittstelle Was sie kann
COM2 Sendet von selbst jede Sekunde die wichtigsten Kesselwerte als Text. Nur lesend, feste Auswahl.
COM1 Service-Schnittstelle: Frage und Antwort, binär. Kann alles lesen, was die Steuerung kennt, und auch schreiben.

COM2 ist der schnellere Einstieg, COM1 der vollständige. Hier geht es um COM1.

Vorab: das ist ein Bericht, keine Anleitung

Ab hier steht, was ich an meinem eigenen Kessel gemacht habe. Ich empfehle niemandem, es nachzumachen.

  • Hinter der Verkleidung liegt Netzspannung. Arbeiten an elektrischen Anlagen sind Sache einer Elektrofachkraft. Wer keine ist, lässt die Verkleidung zu und fragt den Heizungsbauer oder den Kundendienst des Herstellers.
  • Vor dem Öffnen den Kessel an der Steuerung ausschalten und ausbrennen lassen. Erst dann am Heizungsnotschalter spannungsfrei schalten und gegen Wiedereinschalten sichern.
  • Ein Eingriff kann Garantie, Gewährleistung und Versicherungsschutz kosten. Es gilt die Anleitung des Herstellers, nicht dieser Beitrag.
  • Wer den Kessel trotzdem öffnet, tut das auf eigene Gefahr. Für Schäden an Kessel, Anlage oder Personen übernehme ich keine Haftung.

Wo COM1 sitzt

Vielleicht geht es ohne Aufschrauben. Von oben sieht man seitlich am Gehäuse der Steuerung eine neunpolige Buchse. Ob das COM1 ist, weiß ich nicht.

Blick von oben neben das Display: neunpolige Buchse im Blech des Steuerungsgehäuses, mit Pfeil markiert

Ich habe stattdessen die Frontverkleidung abgeschraubt, an den vier gelb markierten Stellen:

Fröling P4 von vorn, vier gelbe Punkte markieren die Schrauben der Frontverkleidung

Dahinter liegt die Platine der Lambdatronic:

Geöffneter Kessel: Platine der Lambdatronic 3200, ein Pfeil zeigt auf den COM1-Anschluss am linken Rand

COM1 ist am linken Rand, direkt unter COM2, und als „Service-Schnittstelle" beschriftet:

Nahaufnahme der Platine: Anschlüsse COM2 und COM1 „Service-Schnittstelle", auf COM1 steckt ein serielles Kabel

Auf COM1 steckt bei mir ein gewöhnliches serielles Kabel. Mehr ist es nicht: nichts gelötet, keine Pins abgegriffen. Das andere Ende habe ich nach außen in die Verkleidung gelegt. Dort steckt ein USB-Seriell-Adapter, und von dem geht es per USB zum Raspberry Pi:

Neunpolige Buchse in der Frontverkleidung des Kessels, darin ein USB-Seriell-Adapter mit USB-Kabel

Die Hardware

  • Kabel: seriell, neunpolig, als Nullmodem gekreuzt: Pin 2 auf 3, 3 auf 2, 5 auf 5 (Masse). Mehr Adern braucht es nicht.
  • Am Raspberry: ein USB-Seriell-Adapter. Er erscheint als /dev/ttyUSB0.
  • Parameter: 57600 Baud, 8 Datenbits, keine Parität, 1 Stoppbit, kein Handshake.

Der Rahmen

Jede Nachricht sieht in beide Richtungen so aus. Hier die Anfrage nach den Betriebsstunden:

02 FD    00 03    30       00 62    F2
Start    Länge    Befehl   Daten    Prüfsumme
  • 02 FD leitet jeden Rahmen ein.
  • Länge zählt Daten plus Prüfsumme, zwei Byte, höherwertiges zuerst.
  • Befehl ist ein Byte, die Antwort trägt denselben.
  • Prüfsumme ist ein Byte über alles davor, 02 FD eingeschlossen:
TypeScript
function checksum(bytes: ArrayLike<number>): number {
    let crc = 0;
    for (let i = 0; i < bytes.length; i++) {
        crc ^= bytes[i] ^ ((bytes[i] << 1) & 0xff);
    }
    return crc;
}

Die Antwort auf das Beispiel ist 02 FD 00 03 30 10 E2 42: 10E2 sind 4322 Stunden.

Die Maskierung

Fünf Bytewerte dürfen hinter 02 FD nicht roh über die Leitung:

Byte wird gesendet als
02 02 00
2B 2B 00
FE FE 00
11 FE 12
13 FE 14

11 und 13 sind XON und XOFF, 02 wäre ein neuer Rahmenanfang. Beim Empfang wird dasselbe rückwärts aufgelöst.

Die erste Falle: Länge und Prüfsumme werden vor dem Maskieren berechnet und ändern sich dadurch nicht. Die Boardtemperatur (Adresse 0002) geht als 02 FD 00 03 30 00 02 00 52 hinaus. Das sind neun Byte, und trotzdem steht 00 03 als Länge drin.

Maskiert wird außerdem auch die Länge selbst. Die Anfrage nach der Werteliste hat Länge 2 und sieht deshalb so aus: 02 FD 00 02 00 31 01 57. Das 00 hinter der 02 gehört zur Länge, nicht zu den Daten.

Beim Empfang muss byteweise aufgelöst werden, nicht auf einem Hex-String. In "002004" steckt ein 0200, das es als Bytes gar nicht gibt. Die Bytes sind 00, 20 und 04.

TypeScript
const ESCAPE: Record<number, number[]> = {
    0x02: [0x02, 0x00],
    0x2b: [0x2b, 0x00],
    0xfe: [0xfe, 0x00],
    0x11: [0xfe, 0x12],
    0x13: [0xfe, 0x14],
};

const PING = 0x22;

function buildFrame(command: number, data: number[] = []): Uint8Array {
    const withCrc = command !== PING; // der Ping kommt ohne Prüfsumme
    const length = data.length + (withCrc ? 1 : 0);
    const frame = [0x02, 0xfd, length >> 8, length & 0xff, command, ...data];
    if (withCrc) frame.push(checksum(frame));

    const out = [0x02, 0xfd];
    for (const byte of frame.slice(2)) out.push(...(ESCAPE[byte] ?? [byte]));

    return Uint8Array.from(out);
}

function unescapeFrame(raw: ArrayLike<number>): number[] {
    const out = [raw[0], raw[1]];
    for (let i = 2; i < raw.length; i++) {
        const [byte, next] = [raw[i], raw[i + 1]];
        if (byte === 0xfe && next === 0x12) {
            out.push(0x11);
            i++;
        } else if (byte === 0xfe && next === 0x14) {
            out.push(0x13);
            i++;
        } else if ([0x02, 0x2b, 0xfe].includes(byte) && next === 0x00) {
            out.push(byte);
            i++;
        } else out.push(byte);
    }
    return out;
}

// null, solange der Rahmen noch nicht vollständig angekommen ist
function parseFrame(
    raw: ArrayLike<number>,
): { command: number; data: number[] } | null {
    const bytes = unescapeFrame(raw);
    if (bytes.length < 5 || bytes[0] !== 0x02 || bytes[1] !== 0xfd) return null;

    const length = (bytes[2] << 8) | bytes[3];
    if (bytes.length < 5 + length) return null;

    const command = bytes[4];
    if (command === PING) return { command, data: bytes.slice(5, 5 + length) };

    const end = 4 + length;
    if (checksum(bytes.slice(0, end)) !== bytes[end])
        throw new Error('Prüfsumme falsch');

    return { command, data: bytes.slice(5, end) };
}

Der Puffer wird bei jedem neuen Stück vom Anfang an neu aufgelöst. Sonst bricht es, wenn ein 02 am Ende des einen Stücks und das 00 am Anfang des nächsten ankommt.

Die Befehle, die ich benutze

Befehl Daten Antwort
22 beliebiger Text derselbe Rahmen zurück, als Verbindungstest
30 Adresse (2 Byte) der aktuelle Wert, 2 Byte
31 01 erster Eintrag der Werteliste
32 01 nächster Eintrag, bis die Liste durch ist
41 keine Softwareversion, Uhrzeit, Datum
47 keine erster Eintrag im Fehlerpuffer
48 keine nächster Eintrag; Daten 00 heißt: keiner mehr
51 keine Betriebsart und Zustand als Text, etwa Automatik;Heizen

Ich schicke eine Anfrage, warte auf die Antwort und schicke dann die nächste.

Aufpassen beim Fehlerpuffer: Auf 48 kommt die Antwort mit Befehl 47 zurück. Wer auf den gesendeten Befehl prüft, verwirft jeden Fehler ab dem zweiten.

Werte lesen: Adresse, Faktor, Vorzeichen

Ein Wert ist eine vorzeichenbehaftete 16-Bit-Zahl, geteilt durch einen Faktor. Temperaturen stehen in halben Grad, also Faktor 2:

Wert Adresse Faktor Anfrage
Kesseltemperatur 0000 2 02 FD 00 03 30 00 00 54
Außentemperatur 0004 2 02 FD 00 03 30 00 04 58
Restsauerstoff 0003 10 02 FD 00 03 30 00 03 51
Betriebsstunden 0062 1 02 FD 00 03 30 00 62 F2
Füllstand im Pelletsbehälter 0071 207 02 FD 00 03 30 00 71 C7

Das Vorzeichen ist wichtig. Bei −3,5 °C draußen kommt FF F9. Als vorzeichenlose Zahl gelesen sind das 32 764,5 °C.

Die Adressen fragst du am besten deine eigene Steuerung. Die Adresslisten, die im Netz kursieren, passen am P4 nicht überall. Den Füllstand (0071) etwa führt die, die ich hatte, gar nicht. Die Befehle 31 und 32 liefern für jeden Wert Faktor, Einheit, Adresse und Bezeichnung:

01   00 02    00 02   00 B0     00 00     4B 65 73 … 00
?    Faktor   ?       Einheit   Adresse   „Kesseltemperatur", nullterminiert

Die Texte sind übrigens Latin-1 kodiert, nicht UTF-8. 52 FC 68 72 … ist „Rührwerk", und als UTF-8 gelesen steht dort ein Ersatzzeichen. Dasselbe gilt für Fehlertexte und den Kesselzustand.

Der Fehlerpuffer

Ein Eintrag aus 47/48, nach dem Auflösen:

01 00   01       42     04        3A 1C 07      03 0C 0C       Kesseltemperaturfühler fehlerhaft
?       Nummer   Info   Zustand   Sek Min Std   Tag Mon Jahr   Text
  • Zustand: 01 gekommen, 02 quittiert, 04 gegangen.
  • Info ist ein Bitfeld: Bit 1 Kessel, Bit 2 Austragung, Bit 3 Umfeld, Bit 5 Störung, Bit 6 Warnung, Bit 7 noch nicht quittiert.

Das Ganze in Node

Mit dem Paket serialport und den Funktionen oben:

TypeScript
import { SerialPort } from 'serialport';

const port = new SerialPort({
    path: '/dev/ttyUSB0',
    baudRate: 57600,
    dataBits: 8,
    parity: 'none',
    stopBits: 1,
});

function request(
    frame: Uint8Array,
    timeoutMs = 3000,
): Promise<{ command: number; data: number[] }> {
    return new Promise((resolve, reject) => {
        let buffer = Buffer.alloc(0);

        const done = (
            error: Error | null,
            answer?: { command: number; data: number[] },
        ) => {
            clearTimeout(timer);
            port.off('data', onData);
            error ? reject(error) : resolve(answer!);
        };
        const timer = setTimeout(
            () => done(new Error('Keine Antwort')),
            timeoutMs,
        );

        function onData(chunk: Buffer) {
            buffer = Buffer.concat([buffer, chunk]);
            try {
                const answer = parseFrame(buffer);
                if (answer) done(null, answer);
            } catch (error) {
                done(error as Error);
            }
        }

        port.on('data', onData);
        port.write(frame);
    });
}

const answer = await request(buildFrame(0x30, [0x00, 0x04]));
console.log(Buffer.from(answer.data).readInt16BE(0) / 2, '°C draußen');

Die Werte in Discord

Bei mir landen die Werte in Discord. Ein kleiner Bot antwortet auf !heizung mit dem, was der Raspberry Pi zuletzt abgeholt hat: Status, Pelletverbrauch, Ascheentleerung, Außentemperatur und Fehler. So sehe ich auch von unterwegs, ob die Heizung läuft.

Discord: Auf den Befehl !heizung antwortet ein Bot mit Status, Pelletverbrauch, Ascheentleerung, Außentemperatur und Fehler

Und schreiben?

Kann COM1 auch: Einstellungen (39), Uhrzeit (54), Heizzeiten (50) und über das sogenannte Forcen einzelne Ein- und Ausgänge. Dazu steht hier nichts, und das ist Absicht. Ich lese nur. Ein falsch gesetzter Ausgang an einem Pelletkessel ist nichts, was man ausprobiert, um zu sehen, was passiert.

Fazit

Das Protokoll selbst ist überschaubar. Aufwendig sind nur ein paar Kleinigkeiten:

  1. Länge und Prüfsumme vor dem Maskieren berechnen,
  2. auch die Länge maskieren,
  3. byteweise auflösen, nicht auf einem Hex-String,
  4. auf 48 kommt 47 zurück,
  5. Texte sind Latin-1, Werte vorzeichenbehaftet.

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.