/** * The typed-event taxonomy for the UniFi Protect realtime stream, the pure {@link classifyPacket} that lifts a {@link RawPacket} into it, and the bootstrap-dimension * {@link findUnmodeledDeviceCollections} that lifts an applied bootstrap into the device collections the reducer does not model. * * This module is Layer 1: it depends only on the codec's output shape and the domain types, performs no I/O, holds no state, and never throws. It is the second half * of the protocol contract - {@link ../protocol/packet | packet.ts} turns bytes into a `RawPacket`; this turns a `RawPacket` into a {@link TypedEvent}. Because it owns * the modelKey vocabulary ({@link DEVICE_MODEL_KEYS} / {@link KNOWN_MODEL_KEYS}), it also hosts the bootstrap-dimension classifier: where `classifyPacket` catches * an unmodeled class that *emits a realtime packet*, `findUnmodeledDeviceCollections` catches one that merely *lives in the bootstrap* - the standing twin the realtime * tripwire is structurally blind to. Both lifts read the same vocabulary, so a class becoming modeled drops out of both with a single edit. * * @module ProtectEvents */ import type { DeepPartial, ProtectNvrBootstrap, ProtectStateRecord } from "../types/index.ts"; import type { RawPacket } from "./packet.ts"; declare const DEVICE_MODEL_KEYS: readonly ["camera", "chime", "fob", "light", "nvr", "relay", "sensor", "viewer"]; /** @internal */ declare const STATE_MODEL_KEYS: readonly ["camera", "chime", "fob", "light", "nvr", "relay", "sensor", "viewer", "liveview", "user"]; declare const KNOWN_MODEL_KEYS: readonly ["camera", "chime", "fob", "light", "nvr", "relay", "sensor", "viewer", "liveview", "user", "activeSessionStat", "aiport", "aiprocessor", "bridge", "event", "group", "linkstation", "smartDetectObject"]; /** * The model keys addressable via a REST endpoint and a Layer-3 projection. The innermost tier - a subset of {@link StateModelKey}. */ export type DeviceModelKey = typeof DEVICE_MODEL_KEYS[number]; /** * The model keys the library reduces into canonical {@link ProtectState} *from the realtime stream*: the devices, the NVR, the user roster, and the liveview collection. * A superset of `DeviceModelKey` (it adds `user` and `liveview`, reduced but not device-addressed) and a subset of `ModelKey` (the full observed wire vocabulary). * * "Reduced into state" and "is a `StateModelKey`" are two orthogonal axes. `applyBootstrap` reconciles *every* reduced collection (it is the universal input), so a * collection can live in {@link ProtectState} without being a `StateModelKey`: `ringtones` is reduced and observable, but the controller broadcasts no `ringtone` packet, * so it advances bootstrap-only and is intentionally excluded here (and from `ModelKey`). `StateModelKey` is precisely the subset the controller *also* emits * realtime deltas for - the keys the reducer's realtime upsert/patch/remove path touches. */ export type StateModelKey = typeof STATE_MODEL_KEYS[number]; /** * Every Protect model key the realtime stream is observed to address: the reduced {@link StateModelKey}s, the recognized-but-unmodeled collections (`group`, `bridge`, * and the newer device-shaped classes), and `event` (the activity-occurrence channel). The library recognizes these but reduces only the {@link StateModelKey} subset; * the rest are carried here purely so the `unifi-protect:schema:unknownModelKey` diagnostic stays honest - a key *outside* this union is genuine schema drift the * controller has introduced, surfaced for visibility, whereas a recognized-but-unmodeled key is a deliberate non-reduction, not drift. `ringtone` is deliberately *not* * here: it is reduced bootstrap-only, never seen on the realtime stream, so leaving it out keeps the tripwire armed should the controller ever start broadcasting it. */ export type ModelKey = typeof KNOWN_MODEL_KEYS[number]; /** * The known UniFi Protect smart-detection object classes. Modeled as an open union: the listed literals give editor autocomplete and exhaustiveness hints for the * common cases, while the trailing `string` keeps the type honest against firmware that introduces new detection classes the library has not enumerated yet. */ export type SmartDetectType = "animal" | "face" | "licensePlate" | "package" | "person" | "vehicle" | (string & {}); /** * The authentication method a doorbell {@link TypedEvent | `authDetected`} occurrence resolved: a fingerprint match or an NFC card tap. The deliberate counterpoint to * {@link SmartDetectType}: that one is *open* (`| (string & {})`) because `objectTypes` passes wire-provided detection-class strings through verbatim and must stay * honest against new firmware classes; this one is *closed*, because `method` is not passed through but *computed* by the classifier from the enumerated auth `type` * values via a single map - the library can only ever produce a value it mapped, so a new auth method is an explicit extension of this union and that map together, * never a silent wire passthrough. */ export type AuthMethod = "fingerprint" | "nfc"; /** * The discriminated union of every realtime event the library models, exposed on the firehose ({@link ProtectClient.events}) and by {@link ProtectClient.classifyPacket}. * It splits into two categories by relationship to state: * * **State transitions** advance {@link ProtectState} - they describe the current condition of devices and the NVR: * * - `deviceAdded` - a device appeared (or its full record was re-sent); the reducer upserts it. * - `devicePatched` - a partial update to an existing device; the reducer deep-merges it. * - `deviceRemoved` - a device was removed; the reducer deletes it. * - `bootstrapLoaded` - a fresh full bootstrap (initial connect, periodic refresh, or post-reboot recovery); the reducer reconciles state against it. Synthetic: * dispatched by the StateStore, never produced by `classifyPacket`. * * **Activity signals** describe discrete occurrences - a thing that happened, with its own `eventId`, lifecycle, and metadata. They are classified *solely* from the * `event` model key (the only place an occurrence carries identity and metadata), flow only to the `client.events()` firehose, and are no-ops in the reducer: * * - `motionDetected` - raw motion began on a camera. * - `smartDetect` - a smart object class was detected (`objectTypes` carries the classes). * - `tamperDetected` - a camera reported tampering (the controller's `smartDetectTamper` occurrence). A one-way signal with no paired clear and no camera-state field, * unlike motion's `lastMotion` - so the occurrence is its single source of truth. It is a sibling of `motionDetected`, not a `smartDetect` variant: tamper is not a * smart object class and carries no `objectTypes`. * - `authDetected` - a doorbell reported an authentication scan: a fingerprint match (`fingerprintIdentified`) or an NFC card tap (`nfcCardScanned`). Camera-attributed * (the doorbell that performed the scan, resolved via the payload `camera` field) and, like `tamperDetected`, a one-way occurrence with no paired state field, so the * occurrence is its single source of truth. It is decided by its explicit auth `type` *ahead* of the generic `accessEvent` fallback as defensive precedence - live * captures of both methods confirm auth metadata (`fingerprint`/`nfc`) and the `accessEventId` access marker are disjoint, so a scan is never absorbed as a bare access * occurrence even were a future firmware to co-stamp it. The `method` the classifier resolved (`"fingerprint"` / `"nfc"`) is lifted onto the event - the consumer reads * the classifier's own answer rather than re-deriving it from metadata shape, exactly as `smartDetect` lifts `objectTypes`; whether the scan succeeded and which * identity matched stay metadata reads (an unrecognized scan still classifies, carrying e.g. `metadata.fingerprint.ulpId: null`). * - `doorbellRing` - a doorbell was pressed. * - `accessEvent` - a UniFi Access occurrence (door, reader, lock), discriminated within the `event` channel by its access metadata. * - `buttonPressed` - a security-action button was pressed on a fob (or a sensor with a button). Device-attributed (the pressing device, from the * payload's `device` field), so it routes by `deviceId` like `accessEvent`; the classifier lifts `button` (which button) and `pressType` (how it was pressed), and * the device family rides in `metadata.deviceModelKey` since the wire event type is shared across button-bearing devices. * * @category Events */ export type TypedEvent = { kind: "deviceAdded"; modelKey: StateModelKey; id: string; data: ProtectStateRecord; } | { kind: "devicePatched"; modelKey: StateModelKey; id: string; patch: DeepPartial; } | { kind: "deviceRemoved"; modelKey: StateModelKey; id: string; } | { kind: "bootstrapLoaded"; data: ProtectNvrBootstrap; } | { kind: "motionDetected"; cameraId: string; eventId: string; at: number; metadata?: Record; } | { kind: "smartDetect"; cameraId: string; eventId: string; at: number; objectTypes: readonly SmartDetectType[]; metadata?: Record; } | { kind: "tamperDetected"; cameraId: string; eventId: string; at: number; metadata?: Record; } | { kind: "authDetected"; cameraId: string; eventId: string; at: number; method: AuthMethod; metadata?: Record; } | { kind: "doorbellRing"; cameraId: string; eventId: string; at: number; } | { kind: "accessEvent"; deviceId: string; eventId: string; at: number; action: string; metadata?: Record; } | { kind: "buttonPressed"; deviceId: string; eventId: string; at: number; button: string; pressType: string; metadata?: Record; }; /** * Classify a decoded {@link RawPacket} into a {@link TypedEvent}, or `null` if the packet is valid but describes something the library does not model. * * This is a pure, stateless, total function. It reads only the packet's own self-description - the action, the model key, and the payload's discriminating fields - * and never consults prior state, never holds state, and never throws. Returning `null` is the documented signal for "a well-formed packet we do not (yet) model": * an unknown model key, an action we do not handle, a device transition whose payload is not an object, or an `event` packet that does not self-describe as one of * the modeled occurrences. The caller observes the `null` and moves on (logging at debug if it wishes). * * Statelessness is the load-bearing property. Activity signals (`motionDetected`, `smartDetect`, `tamperDetected`, `authDetected`, `doorbellRing`, `accessEvent`) are * derived *exclusively* from the `event` model key, because that is the only packet that carries an occurrence's identity and metadata. Camera state - `lastMotion`, * `lastRing`, `isMotionDetected` - flows through the same classifier as a `devicePatched`; it is never re-synthesized into an activity signal, which would require * remembering the previous value and thus break purity. "Did motion happen?" has exactly one home (the occurrence); "what is the camera's current `lastMotion`?" has * exactly one home (the state). * * @param raw - A structurally valid packet from {@link ../protocol/packet | decodePacket}. * * @returns The classified {@link TypedEvent}, or `null` for a valid-but-unmodeled packet. * * @category Events */ export declare function classifyPacket(raw: RawPacket): TypedEvent | null; /** * The id(s) of the record(s) a {@link TypedEvent} concerns - the single, total mapping from an event to its subject(s), shared by every consumer that attributes an event * to a device (the CLI's `--device` filter and event rendering today; any future generic consumer tomorrow). A state transition concerns the one reduced record it * carries; a camera activity signal (`motionDetected`, `smartDetect`, `tamperDetected`, `authDetected`, `doorbellRing`) concerns its camera; an access occurrence * concerns its access device; the synthetic `bootstrapLoaded` concerns no single record and yields the empty array. * * Exhaustive by construction: a new {@link TypedEvent} kind that does not declare its subject is a *compile* error here (the `default` narrows to `never`), so * attribution can never silently drift from the union - the failure a per-consumer re-derivation would otherwise surface as silently dropped events on a device filter. * Pure and total; surfaced to consumers as {@link ProtectClient.eventSubjects} so there is one attribution path, on the object model, mirroring the codec statics. * * @param event - Any typed event from the firehose. * * @returns The subject record id(s), possibly empty. * * @throws {@link ProtectProtocolError} only if an unrecognized event kind reaches this function - a defect the exhaustiveness guard converts from a silently dropped * attribution into a loud failure. * * @category Events */ export declare function eventSubjects(event: TypedEvent): readonly string[]; /** * A populated, device-shaped bootstrap collection the reducer does not model. The pure result of {@link findUnmodeledDeviceCollections}; the store lifts it verbatim onto * the `schema:unmodeledCollection` diagnostic. `known` distinguishes a recognized-but-unreduced class (a {@link ModelKey} member such as `aiport`, which the library * recognizes but does not project) from genuine schema drift the controller introduced (a `modelKey` outside the vocabulary entirely). * * @category Events */ export interface UnmodeledCollection { collection: string; count: number; exampleId: string; known: boolean; modelKey: string; } /** * The bootstrap-dimension twin of {@link classifyPacket}: scan an applied bootstrap for device-shaped collections the reducer does not model. Pure, total, and * side-effect-free - it reads only the bootstrap and the modelKey vocabulary beside it. A collection qualifies when its value is a non-empty array whose first element * is a record carrying both a string `modelKey` and a string `mac` - the device shape, present on relay/aiport/aiprocessor/linkstation/bridge and absent on the * user/liveview/ringtone rosters (no `mac`) and the group collections (no `modelKey`+`mac`) - and whose `modelKey` is not already a {@link DeviceModelKey} the library * models. For each survivor it reports the bootstrap array key (`collection`), the record's self-declared `modelKey`, the array length (`count`), the first record's id * (`exampleId`), and whether the `modelKey` is otherwise recognized (`known`, via {@link isKnownModelKey}) - `true` for a recognized-but-unreduced class, `false` for * genuine drift. * * Where {@link classifyPacket} catches an unmodeled class that emits a realtime packet, this catches a quiescent one that merely lives in the bootstrap. Both read the * same vocabulary, so a class the library already models is automatically excluded from both, with no separate edit needed here. * * @param bootstrap - The applied bootstrap document. * * @returns Zero or more unmodeled device collections, one per qualifying bootstrap key. * * @category Events */ export declare function findUnmodeledDeviceCollections(bootstrap: ProtectNvrBootstrap): readonly UnmodeledCollection[]; /** * Whether the given wire model key is one the library recognizes at all - any {@link StateModelKey}, the recognized-but-unmodeled `group`/`bridge`, or * `event`. The single source of truth for the recognized model-key vocabulary: the `EventStream` consults it to distinguish a `classifyPacket` `null` that means * "genuine schema drift the controller introduced" (a key *outside* this vocabulary, worth surfacing on the `schema:unknownModelKey` diagnostic) from a `null` that * merely means "a recognized key we chose not to lift" (a non-self-describing `event`, an unhandled action, or a recognized-but-unmodeled collection). Keeping the test * here, beside the vocabulary it checks, means a new category is added in exactly one place. * * @param value - The wire model-key string. * * @returns `true` if the model key is one the library recognizes, `false` if it is unrecognized (genuine drift). * * @category Events */ export declare function isKnownModelKey(value: string): value is ModelKey; /** * Whether the given wire model key is one the library models as a device - a {@link DeviceModelKey} with a REST endpoint and a Layer-3 projection. The innermost of the * three nested vocabulary guards ({@link isKnownModelKey} is the outermost). {@link findUnmodeledDeviceCollections} uses it to drop a bootstrap collection the library * already models, so a class promoted into {@link DEVICE_MODEL_KEYS} (as `relay` was) leaves the unmodeled report automatically, with no second edit. * * @param value - The wire model-key string. * * @returns `true` if the model key is a modeled device class. * * @category Events */ export declare function isDeviceModelKey(value: string): value is DeviceModelKey; export {}; //# sourceMappingURL=events.d.ts.map