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.
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.

Ich habe stattdessen die Frontverkleidung abgeschraubt, an den vier gelb markierten Stellen:
Dahinter liegt die Platine der Lambdatronic:

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

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:

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 FDleitet 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 FDeingeschlossen:
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.
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:
01gekommen,02quittiert,04gegangen. - 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:
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.

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:
- Länge und Prüfsumme vor dem Maskieren berechnen,
- auch die Länge maskieren,
- byteweise auflösen, nicht auf einem Hex-String,
- auf
48kommt47zurück, - Texte sind Latin-1, Werte vorzeichenbehaftet.

