/** * davis.js – Gemeinsames Modul für die serielle Kommunikation mit der * Davis Vantage Pro 2 Wetterstation. * * Exports: * PORT_PATH, BAUD_RATE – Verbindungseinstellungen * makeReader(port) – Byte-Accumulator für einen Raw-SerialPort * wakeUp(port, readBytes) – Wakeup-Sequenz (Raw-Port, 3 Versuche) * connectStation() – Raw SerialPort öffnen; gibt {port, readBytes, disconnect} zurück * wakeUpStation(station) – Wakeup über station.port / station.readBytes * fetchLoopData(station) – LOOP1-Datensatz direkt lesen & normalisieren * readArchiveSince(since, onProgress?) – Archiv-Download via DMPAFT */ import "dotenv/config"; import { SerialPort } from "serialport"; import { createRequire } from "module"; const require = createRequire(import.meta.url); const { CRC } = require("crc-full"); // ── Einstellungen ────────────────────────────────────────────────────────── export const PORT_PATH = process.env.PORT_PATH ?? "/dev/ttyUSB0"; export const BAUD_RATE = Number(process.env.BAUD_RATE ?? 19200); const RAIN_CLICK = 0.2; // mm pro Klick (0,2 mm-Sammler) // ── CRC16-CCIT-ZERO (Davis-Protokoll) ───────────────────────────────────── const crc16 = CRC.default("CRC16_CCIT_ZERO"); const makeCRC = (data) => { const v = crc16.compute(data); const b = Buffer.alloc(2); b.writeUInt16BE(v); return b; }; const checkCRC = (buf) => crc16.compute(buf.slice(0, -2)) === buf.readUInt16BE(buf.length - 2); // ── Datum-Hilfsfunktionen ────────────────────────────────────────────────── function dateToWords(date) { const word = ((date.getFullYear() - 2000) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); const time = date.getHours() * 100 + date.getMinutes(); const buf = Buffer.alloc(4); buf.writeUInt16LE(word, 0); buf.writeUInt16LE(time, 2); return buf; } function parseDateTime(dateWord, timeWord) { const year = ((dateWord >> 9) & 0x7F) + 2000; const month = (dateWord >> 5) & 0x0F; const day = dateWord & 0x1F; return new Date(year, month - 1, day, Math.floor(timeWord / 100), timeWord % 100); } // ── Einheitenumrechner (für Archiv- und LOOP-Rohdaten) ──────────────────── const fTenthToC = (raw) => raw === -32768 ? null : +((raw / 10 - 32) * 5 / 9).toFixed(1); const mphToKmh = (raw) => raw === 255 ? null : +(raw * 1.60934).toFixed(1); const inHgToHPa = (raw) => raw === 0 ? null : +(raw / 1000 * 33.8639).toFixed(1); // Windrichtung aus Archiv-Byte (0–15 Index à 22,5°), 255 = kein Wert const archiveWindDir = (raw) => raw === 255 ? null : raw * 22.5; // ── Raw-Port-Helfer ──────────────────────────────────────────────────────── /** * Gibt eine `readBytes(n, timeout)` Funktion zurück, die Bytes aus einem * Raw-SerialPort akkumuliert und auflöst, sobald n Bytes angekommen sind * oder der Timeout abgelaufen ist. */ export function makeReader(port) { let buf = Buffer.alloc(0); let waiter = null; port.on("data", d => { buf = Buffer.concat([buf, d]); waiter?.(); }); return (n, timeout = 5000) => new Promise(res => { const tryResolve = () => { if (buf.length >= n) { const chunk = buf.slice(0, n); buf = buf.slice(n); waiter = null; res(chunk); } }; const timer = setTimeout(() => { waiter = null; const chunk = buf; buf = Buffer.alloc(0); res(chunk); }, timeout); waiter = () => { if (buf.length >= n) { clearTimeout(timer); tryResolve(); } }; tryResolve(); }); } // ── Wakeup (Raw-Port) ────────────────────────────────────────────────────── /** * Weckt die Davis-Console auf einem Raw-SerialPort. * Bis zu 3 Versuche à 1,2 s (jede zweite Wakeup-Anfrage bleibt ohne Antwort – * normales Davis-Verhalten). */ export async function wakeUp(port, readBytes) { for (let tries = 0; tries < 3; tries++) { port.write("\n"); const resp = await readBytes(2, 1200); if (resp.length >= 2) return; } throw new Error("Console antwortet nicht auf Wakeup"); } // ── Station (persistente Raw-Verbindung) ─────────────────────────────────── /** * Öffnet eine persistente Raw-SerialPort-Verbindung zur Davis-Console. * Gibt ein Station-Objekt zurück: { port, readBytes, disconnect } */ export async function connectStation() { const port = new SerialPort({ path: PORT_PATH, baudRate: BAUD_RATE, autoOpen: false }); await new Promise((res, rej) => port.open(err => err ? rej(err) : res())); const readBytes = makeReader(port); const disconnect = () => new Promise(res => setTimeout(() => port.close(res), 200)); return { port, readBytes, disconnect }; } /** * Weckt die Davis-Console über das station.port / station.readBytes. */ export async function wakeUpStation(station) { await wakeUp(station.port, station.readBytes); } // ── LOOP1-Datensatz parsen ───────────────────────────────────────────────── // // Offsets im 99-Byte LOOP1-Paket (gemäß Davis Serial Communication Reference // und vantjs-Quellcode parseLOOP1.js): // // 7 UINT16 LE Luftdruck (inHg × 1000), null wenn 0 // 9 INT16 LE Innentemperatur (°F × 10), null wenn 32767 // 11 UINT8 Innenluftfeuchte (%), null wenn 0 oder 255 // 12 INT16 LE Außentemperatur (°F × 10), null wenn 32767 // 14 UINT8 Windgeschwindigkeit (mph) // 15 UINT8 Ø-Wind 10 min (mph) // 16 UINT16 LE Windrichtung (Grad 1–360, 0 = kein Wind) // 33 UINT8 Außenluftfeuchte (%), null wenn 0 oder 255 // 41 UINT16 LE Regenrate (Klicks/h) // 43 UINT8 UV-Index (× 0,1), null wenn 255 // 44 INT16 LE Solarstrahlung (W/m²), null wenn 32767 // 50 UINT16 LE Tagesregen (Klicks) // 97-98: \n\r (Ende des Pakets) // 97-98: CRC16 (letzte 2 Bytes) // // Antwort auf „LOOP 1\n": ACK (1 Byte 0x06) + 99 Byte Paket = 100 Byte gesamt function parseLOOP1(pkt) { const temp = (raw) => raw === 32767 ? null : +((raw / 10 - 32) * 5 / 9).toFixed(1); const hum = (raw) => (raw === 0 || raw === 255) ? null : raw; const mph = (raw) => raw === 255 ? null : +(raw * 1.60934).toFixed(1); const r1 = (v) => v !== null && v !== undefined ? +v.toFixed(1) : null; const press = pkt.readUInt16LE(7); const barTrend = pkt.readInt8(3); // 80 = noch keine Trendinformation return { time: new Date(), tempOut: temp(pkt.readInt16LE(12)), tempIn: temp(pkt.readInt16LE(9)), humOut: hum(pkt[33]), humIn: hum(pkt[11]), windAvg: mph(pkt[15]), windGust: mph(pkt[14]), windDir: pkt.readUInt16LE(16) || null, forecast: pkt[89], pressure: press === 0 ? null : r1(press * 33.8639 / 1000), barTrend: barTrend === 80 ? null : barTrend, rain: +(pkt.readUInt16LE(50) * RAIN_CLICK).toFixed(1), rainRate: +(pkt.readUInt16LE(41) * RAIN_CLICK).toFixed(1), }; } /** * Liest einen LOOP1-Datensatz direkt über das serielle Protokoll. * Ruft zuerst wakeUpStation auf. */ export async function fetchLoopData(station) { await wakeUpStation(station); station.port.write("LOOP 1\n"); const resp = await station.readBytes(100, 5000); if (resp.length < 100) throw new Error(`LOOP: zu kurz (${resp.length} Bytes)`); if (resp[0] !== 0x06) throw new Error(`LOOP: kein ACK (0x${resp[0].toString(16)})`); const pkt = resp.slice(1); // 99-Byte-Paket if (!checkCRC(pkt)) throw new Error("LOOP: CRC-Fehler"); return parseLOOP1(pkt); } // ── Archiv-Datensatz parsen ──────────────────────────────────────────────── function parseRecord(buf) { const dateWord = buf.readUInt16LE(0); const timeWord = buf.readUInt16LE(2); if (dateWord === 0xFFFF || dateWord === 0) return null; return { time: parseDateTime(dateWord, timeWord), tempOut: fTenthToC(buf.readInt16LE(4)), tempIn: fTenthToC(buf.readInt16LE(20)), humOut: buf[23] === 255 ? null : buf[23], humIn: buf[22] === 255 ? null : buf[22], windAvg: mphToKmh(buf[24]), windGust: mphToKmh(buf[25]), windDir: archiveWindDir(buf[27]), pressure: inHgToHPa(buf.readUInt16LE(14)), rain: +(buf.readUInt16LE(10) * RAIN_CLICK).toFixed(1), rainRate: +(buf.readUInt16LE(12) * RAIN_CLICK).toFixed(1), }; } // ── Archiv-Download (DMPAFT) ─────────────────────────────────────────────── /** * Lädt alle seit `since` archivierten Datensätze von der Console herunter. * * @param {Date} since – Startzeitpunkt (exklusiv) * @param {Function} onProgress – optionaler Callback(current, total) * @returns {Promise} – Array von normalisierten Records */ export async function readArchiveSince(since, onProgress) { const port = new SerialPort({ path: PORT_PATH, baudRate: BAUD_RATE, autoOpen: false }); await new Promise((res, rej) => port.open(err => err ? rej(err) : res())); const readBytes = makeReader(port); const records = []; try { await wakeUp(port, readBytes); port.write("DMPAFT\n"); const ack1 = await readBytes(1, 3000); if (ack1[0] !== 0x06) throw new Error("Kein ACK nach DMPAFT: 0x" + ack1.toString("hex")); // 4-Byte-Datum + 2-Byte-CRC senden const dtBuf = dateToWords(since); port.write(Buffer.concat([dtBuf, makeCRC(dtBuf)])); // 7-Byte-Antwort-Header: ACK(1) + Seiten(2 LE) + ersterRecord(2 LE) + CRC(2) const hdr = await readBytes(7, 5000); if (hdr[0] !== 0x06) throw new Error("Kein ACK im Header"); if (!checkCRC(hdr.slice(1))) throw new Error("Header CRC-Fehler"); const numPages = hdr.readUInt16LE(1); const firstRec = hdr.readUInt16LE(3); if (numPages === 0) return records; port.write(Buffer.from([0x06])); // Übertragung starten for (let p = 0; p < numPages; p++) { onProgress?.(p + 1, numPages); // 1 Seq-Byte + 5 × 52 Byte Records + 4 Byte Füller + 2 Byte CRC = 267 Byte const page = await readBytes(267, 8000); if (page.length < 267) throw new Error(`Seite ${p}: zu kurz (${page.length} Bytes)`); if (!checkCRC(page)) throw new Error(`Seite ${p}: CRC-Fehler`); const startRec = (p === 0) ? firstRec : 0; for (let r = startRec; r < 5; r++) { const rec = parseRecord(page.slice(1 + r * 52, 1 + (r + 1) * 52)); if (rec && rec.time > since) records.push(rec); } // Nächste Seite anfordern; letzte Seite mit ESC abbrechen port.write(Buffer.from([p < numPages - 1 ? 0x06 : 0x1B])); } } finally { await new Promise(r => setTimeout(r, 300)); port.close(); } return records; }