First commit
This commit is contained in:
298
davis.js
Normal file
298
davis.js
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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)
|
||||
const WIND_DIR = ["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];
|
||||
const windDirStr = (raw) => raw === 255 ? null : (WIND_DIR[raw] ?? null);
|
||||
|
||||
// Windrichtung aus LOOP1-Gradangabe (1–360, 0 = kein Wind)
|
||||
const WIND_STEPS = [22.5,45,67.5,90,112.5,135,157.5,180,202.5,225,247.5,270,292.5,315,337.5,360];
|
||||
const WIND_ABBR = ["NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW","N"];
|
||||
const degToDir = (deg) => {
|
||||
if (!deg) return null;
|
||||
for (let i = 0; i < WIND_STEPS.length; i++) if (deg <= WIND_STEPS[i]) return WIND_ABBR[i];
|
||||
return "N";
|
||||
};
|
||||
|
||||
// ── 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);
|
||||
|
||||
return {
|
||||
time: new Date(),
|
||||
tempOut: temp(pkt.readInt16LE(12)),
|
||||
tempOutHigh: null,
|
||||
tempOutLow: null,
|
||||
tempIn: temp(pkt.readInt16LE(9)),
|
||||
humOut: hum(pkt[33]),
|
||||
humIn: hum(pkt[11]),
|
||||
windAvg: mph(pkt[14]),
|
||||
windHigh: null,
|
||||
windDir: degToDir(pkt.readUInt16LE(16)),
|
||||
windHighDir: null,
|
||||
pressure: press === 0 ? null : r1(press * 33.8639 / 1000),
|
||||
rain: +(pkt.readUInt16LE(50) * RAIN_CLICK).toFixed(1),
|
||||
rainRate: +(pkt.readUInt16LE(41) * RAIN_CLICK).toFixed(1),
|
||||
solarRad: (pkt.readInt16LE(44) === 32767 || pkt.readInt16LE(44) < 0)
|
||||
? null : pkt.readInt16LE(44),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)),
|
||||
tempOutHigh: fTenthToC(buf.readInt16LE(6)),
|
||||
tempOutLow: fTenthToC(buf.readInt16LE(8)),
|
||||
tempIn: fTenthToC(buf.readInt16LE(20)),
|
||||
humOut: buf[23] === 255 ? null : buf[23],
|
||||
humIn: buf[22] === 255 ? null : buf[22],
|
||||
windAvg: mphToKmh(buf[24]),
|
||||
windHigh: mphToKmh(buf[25]),
|
||||
windDir: windDirStr(buf[27]),
|
||||
windHighDir: windDirStr(buf[26]),
|
||||
pressure: inHgToHPa(buf.readUInt16LE(14)),
|
||||
rain: +(buf.readUInt16LE(10) * RAIN_CLICK).toFixed(1),
|
||||
rainRate: +(buf.readUInt16LE(12) * RAIN_CLICK).toFixed(1),
|
||||
solarRad: buf.readUInt16LE(16) === 32767 ? null : buf.readUInt16LE(16),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
Reference in New Issue
Block a user