import { API, Characteristic, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service } from 'homebridge'; import { hapClean as sharedHapClean } from './sensorMap/displayName.js'; import { DEVICE } from './types.js'; /** * Re-export of the HAP Name sanitizer. Kept exported here for tests * that were written against `platform.hapClean` before the helper * moved to `sensorMap/displayName.ts`. The compat layer uses the * shared module directly. */ export declare const hapClean: typeof sharedHapClean; import { normalizeMatchKey, toMatcherSet } from './sensorMap/stationMatch.js'; export { normalizeMatchKey, toMatcherSet }; /** * Common shape for the per-accessory wrapper instances the platform * tracks. Each wrapper exposes a single push-style `setValue` entry * point that the platform's poll tick uses to deliver the freshly * fetched value, performing whatever unit conversion is appropriate for * the underlying HomeKit characteristic. */ export interface SensorAccessory { setValue(rawValue: number): void; /** * Optional hook for per-probe battery state. Implementations that * advertise a Battery sub-service should override this to flip * `StatusLowBattery` based on the boolean. Called by the polling * and realtime fanout in addition to `setValue` on each tick. * * Default no-op for accessories that don't expose a battery — the * platform calls this blindly and lets the wrapper decide whether * to act. */ setBatteryLow?(batteryLow: boolean): void; } export declare class AmbientWeatherSensorsPlatform implements DynamicPlatformPlugin { readonly log: Logger; readonly config: PlatformConfig; readonly api: API; readonly Service: typeof Service; readonly Characteristic: typeof Characteristic; readonly accessories: PlatformAccessory[]; private readonly wrappers; private readonly loggedExcludeHits; private readonly loggedIncludeMisses; private readonly loggedStationFilterDrops; private warnedStationFilterEmpty; private loggedStationFilterSummary; private readonly loggedBatterySuppressions; private readonly loggedDiscoveredStations; private pollTimer; private realtimeSource; private readonly safeModeBindings; private readonly sensorMapV2; private v2Routing; private v2EffectiveMap; private readonly loggedPreservedAccessories; private v2Tracker; private configMode; private catalogBaseline; private catalogAdopted; /** * The catalog version the BATTERY DECODER runs at (§19.6, PR #67 * review F6). The vendor-inverted polarity is a v2-runtime behavior: * it applies only when the v2 sensor-map is actually driving. The * flag-off (opt-out) path and safe mode keep the frozen legacy * decode (version 1) even when the config retains a catalog-3 stamp * (which survives the documented marker-deletion rollback). */ private decoderAdopted; constructor(log: Logger, config: PlatformConfig, api: API); configureAccessory(accessory: PlatformAccessory): void; determineSensorType(sensor: string): string; /** * Compose a HAP-clean accessory displayName from station + sensor * metadata. * * Single-station setups (the vast majority) get just the sensor * label — e.g. "Indoor Temperature" — so the Apple Home tile reads * cleanly without a station prefix. Multi-station setups get the * prefix to disambiguate — e.g. "Backyard Station Indoor * Temperature" — falling back to `${mac_no_colons} ${sensor_label}` * if the user hasn't set a station name on ambientweather.net. * * Why the split: Apple Home's tile only honors a custom Name field * after the user explicitly renames via the Home app (the rename * action flips an internal "user-confirmed" flag; programmatic * `setCharacteristic` from the accessory side doesn't). Until then, * the tile shows `accessory.displayName` verbatim. So for the * single-station case where the station prefix is redundant, we * have to drop it at the displayName level — not at the service * Name level — to get clean tiles by default. * * City/state are intentionally NOT included even though the API * supplies them: HomeKit's room/home hierarchy already gives users a * place to express location, and dragging the geocoded address into * every accessory name produces redundant noise on the device tile. * * Truncates from the right to HAP 2.x's 64-character `Name` limit. */ composeDisplayName(obj: { macAddress: string; info?: { name?: string; }; }, sensorKey: string, isMultiStation: boolean): string; /** * Parse `excludeSensors` entries that target battery sub-services * specifically, rather than entire accessories. Three forms are * accepted, all resolving to a set of AWN battery field names to * suppress: * * - "-batt" e.g. "Lightning Strikes Today-batt" * - "-batt" e.g. "lightning_distance-batt" * - "" e.g. "batt_lightning" * * Any sensor name (friendly or raw) sharing a probe with the target * battery resolves to the same field, so users don't need to know * which accessory is the canonical Battery-sub-service host. The * field-name form is direct and lets users skip the reverse lookup * entirely. * * The primary use case is working around upstream AWN API bugs that * report a battery as low even with known-good cells (e.g. * `batt_lightning` for the WH31L lightning sensor — see README). * * Note: entries that target whole accessories (no `-batt` suffix, * not a battery field name) continue to flow through the existing * per-accessory exclude path; they're not consumed here. So users * can mix battery-suppression entries with accessory-exclusion * entries in the same list freely. */ private buildSuppressedBatteries; parseDevices(json: any): DEVICE[]; sleep: (delay: any) => Promise; fetchDevices(): any; /** * Unregister any cached accessories whose underlying sensor is no * longer present in the API response (or has been excluded via * `excludeSensors` / a per-type toggle being turned off). Matching is * by `context.device.uniqueId` — the stable `${mac}-${sensorKey}` * identifier — rather than by `displayName`. Matching by displayName * caused a regression where any change to the naming convention (e.g. * the colon-strip in HB2 compat, or the station-name rollout in this * branch) made every cached accessory look like an orphan and got * them all unregistered from HAP on the first restart after the * rename. uniqueId is stable across renames and is what the for-loop * downstream uses for UUID generation, so they're the same identity * notion. */ deregisterAccessories(Devices: DEVICE[]): void; discoverDevices(): Promise; /** * Choose how to keep the registered wrappers updated. Two data-source * options: * * "polling" (default) — one platform-level setInterval, REST * fetch every 2 minutes. * "realtime" — opt-in; subscribe to AWN's socket.io * endpoint and push updates as they arrive * (~30s cadence indoors). * * CONSTRAINT (added in 1.6.0): embed display mode is incompatible with * the realtime data source. The combination produces a flood of HAP * Name-characteristic update notifications to every paired iOS * controller, which has been observed to drain phone battery ~5×-7× * faster than normal idle (solmssen, 2026-06-18, ~15 extended sensors * active). Polling caps the notification volume to roughly one batch * per 2 minutes, which keeps the drain negligible while still * delivering live-ish tile values. If the user has selected both, we * coerce to polling and warn — the user's intent ("live value in * tile") is preserved at a slightly slower cadence, which is the right * trade-off for an invisible side effect like battery drain. * * Shared by the v1.6.0 path and the v2 reconciler; the poll/realtime * fanout branches on `this.v2Routing` presence downstream. */ private startDataSource; /** * Flag-gated v2 reconciler (finding-#4 Stage 4, first commit). Runs in * place of the v1.6.0 discoverDevices path when `sensorMapV2` is on * (default ON since beta.17; the explicit opt-out selects the * v1.6.0 path). * * Pipeline: * 1. Fetch the raw AWN station payloads; apply stationFilter. * 2. Initialize persistence under * `storagePath()/plugin-data/ambient-weather/` (NEVER * `persistPath()` — HAP-scan / EISDIR hazard): stale-temp * cleanup, ui-state load, and the platform-owned * DiscoveryTracker (review P1-4) fed with this snapshot's * post-filter observations. * 3. Assemble the effective sensor map (pure) from the tracker's * merged discovery view — compat overrides for legacy configs, * `config.sensorMap` for v2. * 4. Reconcile the cache (review P1-2): orphans are unregistered * EXCEPT preserve-cached accessories (kind/measurement not * inferable — kept with last-known values per §17.3); restored * accessories have their cached structural signature (stored, or * derived via `inferForCachedAccessory` + Battery-service * presence) compared against the row's frozen signature — a * mismatch re-registers the accessory and records a * structural-change notice (§8.4/§9) instead of mutating the HAP * graph in place. * 5. For every enabled, KNOWN, AWN-reported row: build a * v1.7-compatible `context.device` carrying `type` (downgrade * cache recognition) plus `kind`/`measurement`/ * `structuralSignature`, restore-or-create its accessory reusing * the v1.7 UUID, instantiate the row-driven wrapper, and register * it. Build the `(mac, dataPoint) → wrapper` routing map and seed * each wrapper's current value. * 6. Start the poll / realtime data source; both fan out through * `distributeViaRouting` (see distributeViaV2Routing). * * Custom (non-default) dataPoints register through the RESTORED * resolution table (Stage 4's table-restoration commit): a custom row * declaring a `(kind, measurement)` with a concrete wrapper class * resolves, registers, and routes like any known row. Kinds without a * wrapper class (co, leak, contact, occupancy) still resolve * `no-wrapper` and never register. * * Safe mode never reaches here — `didFinishLaunching` routes to * `safeModeStart()` first, before any persistence read that could * quarantine or write a file. */ private discoverDevicesV2; /** * v2 twin of parseDevices' station-level filtering. Announces each * discovered station once per restart (users learn the exact * `stationFilter` strings from these lines), then applies the filter * with v1's matching rules (info.name OR MAC, case-insensitive, * whitespace-trimmed). Log strings and the per-session log-once sets * are shared with the v1 path verbatim — only one path runs per boot, * so the sets never collide. `stationFilter` is applied uniformly in * legacy AND v2 config modes: it's a platform-instance concern (multi- * Home child bridges), not a sensor-map concern, and configMode * detection deliberately doesn't classify it as a legacy toggle. */ private applyStationFilterV2; /** * Fetch the raw AWN station payloads for the v2 path. Same endpoint, * throttle handling, and content-type guard as `fetchDevices`, but * returns the un-parsed per-station shape (macAddress + info + lastData) * so the row-driven router can read every field — including the batt* * datapoints `parseDevices` drops. Returns `undefined` on fetch/parse * failure (caller retries), or `[]` for an empty/non-array response. */ private fetchRawStations; /** * Initialize v2 persistence: clean stale temp files, load the * discovery store into a platform-owned DiscoveryTracker (review * P1-4 — the live pipeline is the producer of the plugin's discovery * registry, a role the retired shadow observer used to fill), and * load the ui-state store. Idempotent — the tracker is created once * and survives the discover-retry recursion. * * The persist dir is `storagePath()/plugin-data/ambient-weather/` — we * deliberately avoid `persistPath()` because HAP-NodeJS scans that tree * and a subdirectory there crashes it with EISDIR (v2.0.0-beta.1). * Never called in safe mode (didFinishLaunching routes to * safeModeStart first), so safe mode still performs zero persistence * reads or writes. */ private v2PersistDir; private v2PersistLog; private initV2Persistence; /** * Feed every post-filter (station, dataPoint) pair into the discovery * tracker and kick a throttled flush. Called at discovery and on * each v2 poll tick, so discovery.json keeps accumulating. */ private observeV2Stations; /** * Resolve the structural signature of a CACHED accessory for * comparison against its row's frozen signature (review P1-2 / §9). * * v2-written caches carry the signature verbatim in * `context.device.structuralSignature`. v1.7-written caches don't — * derive one from the same inputs the signature hashes: inferred * kind + measurement, Battery sub-service presence on the actual HAP * graph, and the row's wrapper descriptor. Derivation necessarily * uses the CURRENT wrapper schemaVersion (the old one is unknowable), * so a schemaVersion bump re-registers only v2-written caches — * v1.7 caches adopt the current version silently. Returns undefined * when nothing can be derived (uninferable cache) — the caller then * adopts the row's signature without re-registration. */ private cachedSignatureFor; /** * Record a structural-change notice (sensor-map §8.4) so the UI can * explain why an accessory was re-registered (and its HomeKit room / * automations detached). Best-effort: a persistence failure logs and * never blocks reconciliation. */ private appendStructuralNoticeV2; /** * Surface effective-map diagnostics. Config-attributable errors (e.g. * a custom row's `no-wrapper` for a kind without a concrete wrapper * class) log at info so the user learns their sensor was rejected; * warnings and attribution-free notes stay at debug. */ private logEffectiveMapDiagnostics; /** * v2 value distribution — the single boundary BOTH transports * converge on: polling delivers raw station payloads here directly, * and realtime reconstructs its per-update stream back into the same * payload shape (`updatesToStationPayloads`) first. * * Sensor VALUES go through the Stage-3 routing mechanism * (`distributeViaRouting`); the standalone batt* datapoints the * router deliberately ignores are read per routing entry through the * shared `resolveBatteryField(effectiveMap, mac, dp)` reader — the * same ownership-adjudicated authority the wrapper's Battery * sub-service was built from — and pushed as HomeKit's `true = low` * boolean. */ private distributeViaV2Routing; /** * Reshape the realtime source's pre-digested `(uniqueId, value)` * updates back into raw per-station payloads so the v2 path routes them * through the SAME `distributeViaRouting` boundary the poll path uses. * The realtime source now forwards every PRESENT field — including the * batt* fields — as its own update, so the reconstructed `lastData` * carries the battery datapoints the shared battery reader consumes. * * The reconstruction uses a NULL-PROTOTYPE record (PR #67 review * R3-F1): a forwarded field literally named `__proto__` (a * prototype-safe own key on the JSON-parsed source) would, on a plain * `{}`, invoke the prototype setter and make an UNRELATED sensor's * value appear as an inherited property — e.g. a crafted * `{"leak1":0,"__proto__":{"batleak1":0}}` could synthesize a healthy * battery reading. A null-prototype record makes every assignment an * own data property with no inheritance, so a battery/state lookup * sees only genuinely reported own fields. */ private updatesToStationPayloads; /** * Safe-mode entrypoint — the reduced pipeline for * `configMode === 'safe-mode'` per sensor-map.md §17.2. Reads * cached accessories that HAP already restored (via * `configureAccessory`), and for each KNOWN native default-map * accessory whose primary service + value characteristic(s) are * already attached, builds a `SafeModeBinding` (see * `src/safeModeBinding.ts`) that pushes fresh values through the * RETAINED `Characteristic` instance's `updateValue()`. It does * NOT construct v1.6.0 wrapper objects — those mutate the HAP * graph via `addService` / `removeService` in their constructors. * Bindings are keyed by normalized uniqueId (MAC uppercased) so * the value-distribution lookup matches regardless of cache * casing. * * What safe mode explicitly does NOT do: * - construct wrapper objects or otherwise mutate the HAP graph * (no `addService` / `removeService`; no attaching a missing * characteristic via `updateCharacteristic`); * - call `registerPlatformAccessories` / `unregisterPlatformAccessories`; * - call `updatePlatformAccessories` (no displayName rewrites); * - write to any plugin persistence file; * - reconcile against `parseDevices`'s "orphan" set; * - run realtime — transport is polling ONLY (realtime would * require interpreting apiKey/applicationKey semantics from the * unsupported config). * * Value distribution runs through `safeModePollAndDistribute()` * (NOT the normal `distribute()` / `parseDevices()` path — those * apply config-derived sensor toggles / include-exclude / station * filters we can't safely interpret in safe mode). It reads the * raw AWN JSON and pushes each bound sensor's value + battery by * uniqueId. Accessories that couldn't be bound — extended-sensor * types, unrecognized cached types, custom dataPoints, or missing * characteristics — stay frozen at their cached HAP values. */ private safeModeStart; /** * Fetch the raw AWN payload and push each (station, dataPoint) * value directly to the matching safe-mode binding — bypassing * `parseDevices()` entirely so config filters (sensor toggles, * include/exclude lists, station filters) never touch the flow. * * Safe mode's contract: the config version is unsupported, so we * cannot interpret its filters safely. We CAN still identify * cached accessories by their uniqueId (`${mac}-${dataPoint}`), * so this path just looks each AWN field up in the binding map * and pushes if we recognize it. Anything else — including * fields that the config's filters would have dropped in normal * mode — is silently ignored. */ private safeModePollAndDistribute; /** * Construct the right sensor-type wrapper for an accessory based on * the cached context.device.type. Returns the wrapper so the platform * can index it by uniqueId for the poll-and-distribute loop. */ private createSensorWrapper; /** * Start the platform-level poll timer. One timer covers every * accessory: on each tick we fetch the full devices payload from AWN * once and fan the values out to wrappers via setValue(). Previously * every wrapper owned its own setInterval, which meant N accessories * triggered N parallel fetches per cycle — racing AWN's 1 req/s * rate limit and getting "saved" only by the disk cache. */ private startPolling; /** * Open a long-lived websocket subscription to AWN's realtime endpoint. * Sensor updates arrive as they happen (typically ~30s cadence * indoors), feed through the same `distribute` fanout the poll path * uses, and end up calling setValue() on the matching wrapper. */ private startRealtime; /** * Fetch fresh values once and push each one into the matching wrapper. * Wrappers not present in the response are simply left untouched on * this tick — HomeKit will keep showing the last known value. */ private pollAndDistribute; /** * Common fanout used by both the polling and realtime data sources. * Looks up each update's wrapper by uniqueId; values for sensors we * never registered (unknown sensor types, excluded by config, etc.) * are silently ignored. */ private distribute; } //# sourceMappingURL=platform.d.ts.map