UNPKG

homebridge-weather-noaa

Version:

Homebridge plugin providing temperature and humidity sensors using the NOAA / NWS API.

482 lines 20.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.NOAAWeatherPlatform = void 0; const fs = __importStar(require("fs")); const path = __importStar(require("path")); const platformAccessory_1 = require("./platformAccessory"); const settings_1 = require("./settings"); const PLUGIN_VERSION = '1.6.0'; const NOAA_BASE = 'https://api.weather.gov'; const REQUEST_TIMEOUT_MS = 10_000; const RESPONSE_BYTE_CAP = 2_000_000; const STATION_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STATION_ID_RE = /^[A-Z0-9]{3,8}$/; const GRID_ID_RE = /^[A-Z]{2,4}$/; /** * NWS rate-limit guidance: requests "may be retried after the limit clears * (typically within 5 seconds)". We anchor backoff floor accordingly. * https://www.weather.gov/documentation/services-web-api */ const RATE_LIMIT_FLOOR_MS = 5_000; const BACKOFF_CEILING_MS = 60_000; const RETRY_AFTER_CAP_MS = 5 * 60_000; const MAX_RETRIES = 4; /** * MADIS QC flags treated as acceptable for HomeKit display: * V = passed all QC checks (best) * C = passed coarse QC checks * S = passed spatial QC checks * Z = no QC performed (raw — many ASOS/AWOS sites report this) * Rejected: X (failed), Q (questionable), B (subjective bad). * https://madis.ncep.noaa.gov/madis_sfc_qc_notes.shtml */ const ACCEPTABLE_QC = new Set(['V', 'C', 'S', 'Z']); const ADAPTIVE_GROW_AFTER_UNCHANGED = 3; const ADAPTIVE_MAX_MULT = 4; class NOAAWeatherPlatform { log; config; api; Service; Characteristic; accessories = new Map(); userAgent; timers = new Set(); metrics = { apiFailures: 0, retryCount: 0, rateLimitedCount: 0, stationCacheResets: 0, }; constructor(log, config, api) { this.log = log; this.config = config; this.api = api; this.Service = api.hap.Service; this.Characteristic = api.hap.Characteristic; this.userAgent = this.buildUserAgent(); this.log.debug('Finished initializing platform:', this.config.name); this.api.on('didFinishLaunching', () => { this.discoverDevices().catch((err) => { this.log.error('Unhandled error in discoverDevices:', err); }); }); this.api.on('shutdown', () => this.shutdown()); const metricsTimer = setInterval(() => this.logMetrics(), 60 * 60 * 1000); metricsTimer.unref(); this.timers.add(metricsTimer); } configureAccessory(accessory) { this.log.info('Loading accessory from cache:', accessory.displayName); this.accessories.set(accessory.UUID, accessory); } shutdown() { for (const t of this.timers) { clearInterval(t); clearTimeout(t); } this.timers.clear(); this.logMetrics(); } /** * NWS-recommended User-Agent format: "(myapp.com, contact)". * https://www.weather.gov/documentation/services-web-api * * The optional contact field is sanitized to strip CR/LF, preventing * HTTP header injection from a misconfigured config.json value. */ buildUserAgent() { const contactRaw = this.config.userAgentContact; const home = 'github.com/Phirtue/homebridge-weather-noaa'; if (typeof contactRaw === 'string' && contactRaw.trim().length > 0) { const clean = contactRaw.trim().replace(/[\r\n]/g, '').slice(0, 200); return `homebridge-weather-noaa/${PLUGIN_VERSION} (${home}, ${clean})`; } return `homebridge-weather-noaa/${PLUGIN_VERSION} (${home})`; } getNumberConfig(key) { const raw = this.config[key]; if (raw === null || raw === undefined || raw === '') { return undefined; } const n = Number(raw); return Number.isFinite(n) ? n : undefined; } getBoolConfig(key, fallback) { const raw = this.config[key]; if (typeof raw === 'boolean') { return raw; } if (typeof raw === 'string') { return raw.toLowerCase() === 'true'; } return fallback; } getStationIdConfig() { const raw = this.config.stationId; if (typeof raw !== 'string' || raw.length === 0) { return null; } const upper = raw.trim().toUpperCase(); if (!STATION_ID_RE.test(upper)) { this.log.warn(`Configured stationId "${raw}" is invalid (expected 3-8 alphanumerics). ` + 'Falling back to auto-discovery.'); return null; } return upper; } /** * Fetch JSON from the NWS API with bounded retries and rate-limit handling. * Native fetch + AbortController; no third-party HTTP client required. */ async fetchJson(url) { let attempt = 0; let backoffMs = RATE_LIMIT_FLOOR_MS; while (attempt <= MAX_RETRIES) { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS); try { const res = await fetch(url, { method: 'GET', headers: { 'User-Agent': this.userAgent, 'Accept': 'application/geo+json, application/ld+json, application/json', }, signal: ac.signal, redirect: 'follow', }); if (res.ok) { const text = await res.text(); if (text.length > RESPONSE_BYTE_CAP) { throw new Error(`Response exceeded ${RESPONSE_BYTE_CAP} bytes (${text.length})`); } return JSON.parse(text); } if (res.status === 429) { this.metrics.rateLimitedCount++; const waitMs = this.parseRetryAfter(res.headers.get('retry-after'), backoffMs); this.log.warn(`NOAA rate-limited (429). Waiting ${(waitMs / 1000).toFixed(1)}s.`); await this.sleep(waitMs); backoffMs = Math.min(backoffMs * 2, BACKOFF_CEILING_MS); attempt++; this.metrics.retryCount++; continue; } if (res.status >= 500 && res.status <= 599) { this.metrics.retryCount++; this.log.warn(`NOAA ${res.status}; retrying in ${(backoffMs / 1000).toFixed(1)}s.`); await this.sleep(backoffMs); backoffMs = Math.min(backoffMs * 2, BACKOFF_CEILING_MS); attempt++; continue; } this.metrics.apiFailures++; throw new Error(`NOAA API ${res.status} ${res.statusText} for ${url}`); } catch (err) { const isAbort = err?.name === 'AbortError'; const code = err?.code; const isNetwork = isAbort || code === 'ENOTFOUND' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || (err instanceof TypeError && /fetch failed/i.test(err.message)); if (isNetwork && attempt < MAX_RETRIES) { this.metrics.retryCount++; this.log.warn(`Network error (${isAbort ? 'timeout' : err.message}); ` + `retrying in ${(backoffMs / 1000).toFixed(1)}s.`); await this.sleep(backoffMs); backoffMs = Math.min(backoffMs * 2, BACKOFF_CEILING_MS); attempt++; continue; } this.metrics.apiFailures++; throw err; } finally { clearTimeout(timer); } } throw new Error(`NOAA API: exhausted ${MAX_RETRIES} retries for ${url}`); } parseRetryAfter(header, fallbackMs) { if (!header) { return fallbackMs; } const asInt = Number(header); if (Number.isFinite(asInt) && asInt >= 0) { return Math.min(Math.max(asInt * 1000, RATE_LIMIT_FLOOR_MS), RETRY_AFTER_CAP_MS); } const asDate = Date.parse(header); if (!Number.isNaN(asDate)) { const delta = asDate - Date.now(); if (delta > 0) { return Math.min(Math.max(delta, RATE_LIMIT_FLOOR_MS), RETRY_AFTER_CAP_MS); } } return fallbackMs; } sleep(ms) { return new Promise((res) => setTimeout(res, ms)); } writeCacheAtomic(file, data) { const tmp = `${file}.${process.pid}.tmp`; try { fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); fs.renameSync(tmp, file); } catch (err) { try { fs.unlinkSync(tmp); } catch { /* ignore */ } this.log.warn(`Failed to persist station cache: ${err.message}`); } } readStationCache(cacheFile, latitude, longitude) { if (!fs.existsSync(cacheFile)) { return null; } try { const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); const ageMs = Date.now() - cache.timestamp; const valid = typeof cache.stationId === 'string' && STATION_ID_RE.test(cache.stationId) && cache.latitude === latitude && cache.longitude === longitude && Number.isFinite(cache.timestamp) && ageMs >= 0 && ageMs < STATION_CACHE_TTL_MS; if (!valid) { return null; } const gridNote = cache.gridId && Number.isFinite(cache.gridX) && Number.isFinite(cache.gridY) ? ` (grid ${cache.gridId}/${cache.gridX},${cache.gridY})` : ''; this.log.info(`Using cached NOAA station: ${cache.stationId}${gridNote}`); return cache.stationId; } catch { this.metrics.stationCacheResets++; this.log.warn('Corrupted NOAA station cache. Rebuilding.'); try { fs.unlinkSync(cacheFile); } catch { /* ignore */ } return null; } } async discoverDevices() { const latitude = this.getNumberConfig('latitude'); const longitude = this.getNumberConfig('longitude'); const refreshMinutes = Math.max(5, this.getNumberConfig('refreshInterval') ?? 15); const baseRefreshMs = refreshMinutes * 60 * 1000; const adaptive = this.getBoolConfig('adaptivePolling', true); const cacheFile = path.join(this.api.user.persistPath(), 'noaa-points-cache.json'); if (latitude === undefined || longitude === undefined || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { this.log.error('Latitude and Longitude must be valid numbers within range. Plugin will not start.'); return; } let stationId = this.getStationIdConfig(); if (stationId) { this.log.info(`Using manually configured NOAA station: ${stationId}`); } else { stationId = this.readStationCache(cacheFile, latitude, longitude); } if (!stationId) { stationId = await this.discoverStation(latitude, longitude, cacheFile); if (!stationId) { return; } } // Stable UUID — preserved from v1.5 so existing HomeKit room assignments // and automations survive the upgrade. const uuid = this.api.hap.uuid.generate('noaa-weather-unique'); let accessory = this.accessories.get(uuid); if (accessory) { this.log.info('Restoring NOAA Weather accessory from cache.'); } else { accessory = new this.api.platformAccessory('NOAA Weather', uuid); this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]); this.accessories.set(uuid, accessory); this.log.info('Created new NOAA Weather accessory.'); } const handler = new platformAccessory_1.NOAAWeatherAccessory(this, accessory, PLUGIN_VERSION); this.startPolling(stationId, handler, baseRefreshMs, adaptive); for (const [cachedUuid, cached] of this.accessories) { if (cachedUuid !== uuid) { this.log.info('Removing stale accessory from cache:', cached.displayName); this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [cached]); this.accessories.delete(cachedUuid); } } } async discoverStation(latitude, longitude, cacheFile) { try { this.log.info(`Fetching NOAA grid data for: ${latitude},${longitude}`); const point = await this.fetchJson(`${NOAA_BASE}/points/${encodeURIComponent(`${latitude},${longitude}`)}`); const props = point.properties ?? {}; const gridId = props.gridId; const gridX = props.gridX; const gridY = props.gridY; if (typeof gridId !== 'string' || !GRID_ID_RE.test(gridId) || !Number.isInteger(gridX) || !Number.isInteger(gridY) || gridX < 0 || gridY < 0) { this.log.error(`Invalid /points response for ${latitude},${longitude}`); return null; } this.log.info(`Grid location: ${gridId}/${gridX},${gridY}`); const stations = await this.fetchJson(`${NOAA_BASE}/gridpoints/${encodeURIComponent(gridId)}/${gridX},${gridY}/stations`); const candidates = (stations.features ?? []) .map((f) => f?.properties?.stationIdentifier) .filter((id) => typeof id === 'string' && STATION_ID_RE.test(id)); if (candidates.length === 0) { this.log.error('No valid NOAA stations found for grid cell.'); return null; } const stationId = candidates[0]; this.log.info(`Station candidates: ${candidates.slice(0, 10).join(', ')}`); this.log.info(`Selected NOAA station: ${stationId}`); this.writeCacheAtomic(cacheFile, { latitude, longitude, gridId, gridX: gridX, gridY: gridY, stationId, timestamp: Date.now(), }); return stationId; } catch (err) { this.log.error('Failed to discover NOAA station:', err.message); return null; } } startPolling(stationId, handler, baseRefreshMs, adaptive) { let unchangedStreak = 0; let inFlight = false; const scheduleNext = () => { let mult = 1; if (adaptive && unchangedStreak >= ADAPTIVE_GROW_AFTER_UNCHANGED) { mult = Math.min(ADAPTIVE_MAX_MULT, 1 + Math.floor(unchangedStreak / ADAPTIVE_GROW_AFTER_UNCHANGED)); } const delay = baseRefreshMs * mult; const t = setTimeout(() => { this.timers.delete(t); // eslint-disable-next-line @typescript-eslint/no-use-before-define tick().catch((err) => this.log.error('Tick error:', err)); }, delay); t.unref(); this.timers.add(t); }; const tick = async () => { if (inFlight) { return; } inFlight = true; try { const changed = await this.fetchAndPushObservation(stationId, handler); unchangedStreak = changed ? 0 : unchangedStreak + 1; } catch (err) { this.log.error('NOAA observation fetch failed:', err.message); unchangedStreak = 0; } finally { inFlight = false; scheduleNext(); } }; tick().catch((err) => this.log.error('Initial tick error:', err)); } async fetchAndPushObservation(stationId, handler) { const data = await this.fetchJson(`${NOAA_BASE}/stations/${encodeURIComponent(stationId)}/observations/latest`); const props = data.properties ?? {}; const tempC = this.extractTemperatureC(props.temperature); const humidity = this.extractHumidity(props.relativeHumidity); const conditions = props.presentWeather?.map((w) => w?.weather).filter(Boolean).join(', ') || 'None'; this.log.info(`NOAA - ts=${props.timestamp ?? 'n/a'} temp=${tempC ?? 'n/a'}°C ` + `humidity=${humidity ?? 'n/a'}% conditions=${conditions}`); return handler.applyReading({ temperature: tempC, humidity }); } /** Convert NWS QuantitativeValue to °C, honoring unitCode and QC flag. */ extractTemperatureC(qv) { if (!qv || qv.value === null || qv.value === undefined) { return null; } if (qv.qualityControl && !ACCEPTABLE_QC.has(qv.qualityControl)) { this.log.debug(`Rejecting temperature with QC=${qv.qualityControl}`); return null; } const unit = (qv.unitCode ?? '').toLowerCase(); if (unit.endsWith('degc') || unit === '') { return qv.value; } if (unit.endsWith('degf')) { return (qv.value - 32) * (5 / 9); } if (unit.endsWith('k')) { return qv.value - 273.15; } this.log.warn(`Unknown temperature unit "${qv.unitCode}", ignoring reading.`); return null; } extractHumidity(qv) { if (!qv || qv.value === null || qv.value === undefined) { return null; } if (qv.qualityControl && !ACCEPTABLE_QC.has(qv.qualityControl)) { this.log.debug(`Rejecting humidity with QC=${qv.qualityControl}`); return null; } return Math.max(0, Math.min(100, qv.value)); } logMetrics() { this.log.info(`NOAA Platform Metrics - failures=${this.metrics.apiFailures} ` + `retries=${this.metrics.retryCount} rateLimited=${this.metrics.rateLimitedCount} ` + `cacheResets=${this.metrics.stationCacheResets}`); } } exports.NOAAWeatherPlatform = NOAAWeatherPlatform; //# sourceMappingURL=platform.js.map