First commit

This commit is contained in:
2026-04-03 22:24:22 +02:00
commit d1cfee0dea
13 changed files with 1464 additions and 0 deletions

298
davis.js Normal file
View 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 (015 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 (1360, 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 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);
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;
}