283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
/**
|
||
* 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[14]),
|
||
windGust: mph(pkt[15]),
|
||
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;
|
||
}
|