Files
wetter-server/davis.js

283 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (015 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 1360, 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>} 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;
}