UNPKG

homebridge-nest-accfactory

Version:

Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras

1,340 lines (1,138 loc) 81.9 kB
// WebRTC // Part of homebridge-nest-accfactory // // Implements WebRTC-based streaming for Google Nest cameras using Google Home // Foyer/gRPC signaling and control. // Handles peer connection setup, RTP media processing, and integration with the // Streamer pipeline for HomeKit live streaming and recording. // // Responsibilities: // - Establish and manage RTCPeerConnection using the werift library // - Use Google Home Foyer gRPC transport for signaling and stream control // - Handle ICE negotiation and connection state lifecycle // - Receive and process RTP packets (H264 video, Opus audio) // - Apply jitter buffering and packet reordering for RTP streams // - Perform H264 NAL unit parsing and frame reassembly (including FU-A), emitting Annex-B frames // - Assemble complete video frames before injecting into Streamer // - Decode Opus audio to PCM for downstream processing // - Inject media into Streamer for live and recording outputs // - Support two-way audio (talkback) via outbound RTP/Opus pipeline // // Features: // - Secure media transport over DTLS-SRTP // - RTCP feedback support (PLI/FIR/NACK) for video recovery // - Codec negotiation (H264 video, Opus audio, RTX video) // - Startup timing and stream diagnostics logging // - Resilient handling of packet loss and stream stalls // // Notes: // - WebRTC signaling and stream control are performed via the shared Google gRPC transport/client // - Video readiness is determined by first video RTP packet arrival, not connection state // - ICE "connected" indicates transport readiness, not media availability // - Startup delays may occur due to upstream (Google) keyframe delivery behaviour // // Code version 2026.05.07 // Mark Hulskamp 'use strict'; // Define external module requirements import * as werift from 'werift'; import { Decoder } from '@evan/opus'; // Define nodejs module requirements import { Buffer } from 'node:buffer'; import { setInterval, clearInterval } from 'node:timers'; import path from 'node:path'; import crypto from 'node:crypto'; // Define our modules import Streamer from './streamer.js'; import GrpcTransport from './grpctransport.js'; // Define constants import { USER_AGENT, __dirname } from './consts.js'; const EXTEND_INTERVAL = 30000; // Send extend command to Google Home Foyer every this period for active streams const GOOGLE_HOME_FOYER_REQUEST_TIMEOUT = 15000; // Client-side timeout for Google Home Foyer gRPC requests const GOOGLE_HOME_FOYER_BUFFER_INITIAL = 8 * 1024; // Initial 8KB buffer for gRPC responses const GOOGLE_HOME_FOYER_BUFFER_MAX = 10 * 1024 * 1024; // Maximum 10MB buffer limit const RTP_SEQUENCE_WRAP = 0x10000; // For wrapping sequence calculations const RTP_SEQUENCE_MASK = 0xffff; // 16-bit RTP sequence number mask const RTP_TIMESTAMP_MASK = 0x100000000; // 32-bit RTP timestamp wrap mask const RTP_TIMESTAMP_MAX_DELTA = 0x7fffffff; // Max positive delta for timestamp comparison const RTP_PACKET_HEADER_SIZE = 12; // RTP packet header size in bytes const RTP_H264_VIDEO_PAYLOAD_TYPE = 98; // H.264 video payload type const RTP_H264_VIDEO_RTX_PAYLOAD_TYPE = 99; // H.264 RTX payload type for retransmissions const RTP_OPUS_AUDIO_PAYLOAD_TYPE = 111; // Opus audio payload type const GOOGLE_HOME_FOYER_PREFIX = 'google.internal.home.foyer.v1.'; const TIMESTAMP_MAX_VIDEO_DELTA = 2300; // Track observed ~2.2s IDR assembly windows without forcing aggressive timestamp compression const TIMESTAMP_MAX_KEYFRAME_DELTA = 600; // Cap keyframe playout step more aggressively const TIMESTAMP_VIDEO_MAX_BEHIND = 700; // Keep emitted timestamps from lagging too far behind wall clock const TIMESTAMP_VIDEO_MAX_AHEAD = 700; // Allow variable-FPS bursts without compressing frame emission too aggressively const TIMESTAMP_MAX_AUDIO_DELTA = 160; const TIMESTAMP_AUDIO_RESYNC_BEHIND = 240; // Only hard-resync audio when callback delay has grown materially large const KEYFRAME_MAX_ASSEMBLY_MS = 2500; // Drop pathological keyframes assembled too slowly const KEYFRAME_MAX_BYTES = 140000; // Drop oversized keyframes that cause visible playback shock const HEALTH_BAD_WINDOW_MS = 3000; // Rolling window for stream-health bad events const HEALTH_UNSTABLE_BAD_THRESHOLD = 4; // Enter UNSTABLE when recent bad event score reaches this const HEALTH_RECOVERING_CLEAN_TARGET = 6; // Exit RECOVERING after this weighted clean score const DELTA_FU_SWITCH_GRACE_MS = 180; // Tiny grace before abandoning a young non-keyframe FU-A on timestamp switch const STALLED_TIMEOUT = 10000; // Time with no playback packets before we consider stream stalled and attempt restart const PCM_S16LE_48000_STEREO_BLANK = Buffer.alloc(960 * 2 * 2); // Default blank audio frame (20ms) in PCM S16LE, stereo @ 48kHz // WebRTC object export default class WebRTC extends Streamer { token = undefined; // oauth2 token blankAudio = PCM_S16LE_48000_STEREO_BLANK; // Internal data only for this class #grpcTransport = undefined; // Shared protobuf/gRPC client for Google Home Foyer APIs #streamId = undefined; // Stream ID #googleHomeDeviceUUID = undefined; // Normal Nest/Google protobuf device ID translated to a Google Foyer device ID #googleHomeDeviceUUIDPromise = undefined; // Promise for in-flight HomeGraph lookup of Google Foyer device UUID #peerConnection = undefined; #videoTransceiver = undefined; #audioTransceiver = undefined; #opusDecoder = new Decoder({ channels: 2, sample_rate: 48000 }); #extendTimer = undefined; // Stream extend timer #stalledTimer = undefined; // Interval object for no received data checks #lastPacketAt = undefined; // Last playback packet receipt time in ms #closeInProgress = false; // True while close() teardown is running to avoid re-entrant shutdown races #reconnectPending = false; // Reconnect requested once socket closes #reconnectReason = undefined; // Reason for reconnect #tracks = { audio: {}, video: {}, talkback: {} }; // Track state for audio and video // Codecs being used for video, audio and talking get codecs() { return { video: Streamer.CODEC_TYPE.H264, // Video is H264 audio: Streamer.CODEC_TYPE.PCM, // Audio is PCM (we decode Opus to PCM output) talkback: Streamer.CODEC_TYPE.OPUS, // Talking is also Opus }; } // Capabilities supported by this streamer get capabilities() { return { live: true, record: true, talkback: true, buffering: true, }; } constructor(uuid, deviceData, options) { super(uuid, deviceData, options); // Store data we need from the device data passed it this.token = deviceData?.apiAccess?.oauth2; // Configure Google Home Foyer protobuf/gRPC client. this.#grpcTransport = new GrpcTransport({ log: this.log, protoPath: path.resolve(__dirname + '/protobuf/googlehome/foyer.proto'), endpointHost: deviceData?.apiAccess?.fieldTest === true ? 'https://preprod-googlehomefoyer-pa.sandbox.googleapis.com' : 'https://googlehomefoyer-pa.googleapis.com', uuid: this.nest_google_device_uuid, userAgent: USER_AGENT, requestTimeout: GOOGLE_HOME_FOYER_REQUEST_TIMEOUT, bufferInitial: GOOGLE_HOME_FOYER_BUFFER_INITIAL, bufferMax: GOOGLE_HOME_FOYER_BUFFER_MAX, getAuthHeader: () => (typeof this.token === 'string' && this.token.trim() !== '' ? 'Bearer ' + this.token : ''), }); // Start resolving the Google Home Foyer device UUID in the background so the // first live stream does not always pay the full HomeGraph lookup cost. this.#googleHomeDeviceUUIDPromise = this.#grpcTransport .command( GOOGLE_HOME_FOYER_PREFIX, 'StructuresService', 'GetHomeGraph', { requestId: crypto.randomUUID(), }, { retry: 2, }, ) .then((homeFoyerResponse) => { if (homeFoyerResponse?.data?.[0]?.homes !== undefined) { Object.values(homeFoyerResponse.data[0].homes || {}).forEach((home) => { Object.values(home?.devices || {}).forEach((device) => { if (device?.id?.googleUuid !== undefined && device?.otherIds?.otherThirdPartyId !== undefined) { let currentGoogleUuid = device.id.googleUuid; Object.values(device.otherIds.otherThirdPartyId || {}).forEach((other) => { if (other?.id === this.nest_google_device_uuid) { this.#googleHomeDeviceUUID = currentGoogleUuid; } }); } }); }); } return this.#googleHomeDeviceUUID; }) .catch((error) => { this.log?.warn?.( 'Unable to resolve Google Home device ID for "%s" (%s). Stream video/recording will be unavailable: %s', this.deviceData.description, this.nest_google_device_uuid, String(error), ); return undefined; }) .finally(() => { this.#googleHomeDeviceUUIDPromise = undefined; }); } // Class functions // eslint-disable-next-line no-unused-vars async connect(options = {}) { if ( this.sourceState === Streamer.MESSAGE_TYPE.SOURCE_CONNECTING || this.sourceState === Streamer.MESSAGE_TYPE.SOURCE_CLOSING || (this.#peerConnection !== undefined && this.sourceState !== Streamer.MESSAGE_TYPE.SOURCE_CLOSED) ) { return; } if (this.online !== true || this.videoEnabled !== true) { return; } // Tell the Streamer base that we are beginning source setup. // This is transport/control readiness only and does not mean media is flowing yet. this.setSourceState(Streamer.MESSAGE_TYPE.SOURCE_CONNECTING); // Reset any previous session timers/state before attempting a new connection. // This ensures a reconnect starts from a clean baseline rather than reusing // timers or partially assembled media from an earlier session. clearInterval(this.#extendTimer); clearInterval(this.#stalledTimer); this.#extendTimer = undefined; this.#stalledTimer = undefined; this.#lastPacketAt = undefined; this.#streamId = undefined; this.#reconnectPending = false; this.#reconnectReason = undefined; this.#tracks = { audio: {}, video: {}, talkback: {} }; if (typeof this.#googleHomeDeviceUUID !== 'string' && this.#googleHomeDeviceUUIDPromise instanceof Promise) { await this.#googleHomeDeviceUUIDPromise; } if (this.#closeInProgress === true || this.#peerConnection !== undefined) { return; } if (typeof this.#googleHomeDeviceUUID !== 'string' || this.#googleHomeDeviceUUID === '') { this?.log?.debug?.('Google Home device UUID not resolved for uuid "%s"', this.nest_google_device_uuid); this.setSourceState(Streamer.MESSAGE_TYPE.SOURCE_CLOSED, 'google-device-id-missing'); return; } let homeFoyerResponse = await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'SendCameraViewIntent', { request: { googleDeviceId: { value: this.#googleHomeDeviceUUID, }, command: 'VIEW_INTENT_START', }, }); if (this.#closeInProgress === true || this.#peerConnection !== undefined) { return; } if (homeFoyerResponse?.status !== 0) { this?.log?.debug?.('Request to start camera viewing was not accepted for uuid "%s"', this.nest_google_device_uuid); this.setSourceState(Streamer.MESSAGE_TYPE.SOURCE_CLOSED, 'view-intent-failed'); return; } // Create our local WebRTC peer connection and advertise the codecs we support. // We receive H264 video and Opus audio from the camera, then convert that into // Streamer media items for live view and recording. let peerConnection = new werift.RTCPeerConnection({ iceUseIpv4: true, iceUseIpv6: false, bundlePolicy: 'max-bundle', codecs: { audio: [ new werift.RTCRtpCodecParameters({ mimeType: 'audio/opus', clockRate: 48000, channels: 2, rtcpFeedback: [{ type: 'nack' }], parameters: 'minptime=10;useinbandfec=1', payloadType: RTP_OPUS_AUDIO_PAYLOAD_TYPE, }), ], video: [ new werift.RTCRtpCodecParameters({ mimeType: 'video/H264', clockRate: 90000, rtcpFeedback: [{ type: 'ccm', parameter: 'fir' }, { type: 'nack' }, { type: 'nack', parameter: 'pli' }, { type: 'goog-remb' }], parameters: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f', payloadType: RTP_H264_VIDEO_PAYLOAD_TYPE, }), new werift.RTCRtpCodecParameters({ mimeType: 'video/rtx', clockRate: 90000, parameters: 'apt=' + RTP_H264_VIDEO_PAYLOAD_TYPE, payloadType: RTP_H264_VIDEO_RTX_PAYLOAD_TYPE, }), ], }, headerExtensions: { audio: [werift.useAudioLevelIndication()], }, }); this.#peerConnection = peerConnection; peerConnection.createDataChannel('webrtc-datachannel'); this.#audioTransceiver = peerConnection.addTransceiver('audio', { direction: 'sendrecv', }); this.#videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly', }); // Create our SDP offer and send it to Google Home Foyer. // If accepted, we will get an SDP answer back plus a streamId for later extend/end/talkback calls. let webRTCOffer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(webRTCOffer); homeFoyerResponse = await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'JoinStream', { command: 'offer', deviceId: this.nest_google_device_uuid, local: false, // Request direct peer-to-peer connection if possible streamContext: 'STREAM_CONTEXT_DEFAULT', requestedVideoResolution: 'VIDEO_RESOLUTION_STANDARD', sdp: webRTCOffer.sdp, }); if (this.#peerConnection !== peerConnection) { try { await peerConnection?.close?.(); } catch { // Empty } return; } if ( homeFoyerResponse?.status !== 0 || homeFoyerResponse?.data?.[0]?.responseType !== 'answer' || homeFoyerResponse?.data?.[0]?.streamId === undefined || homeFoyerResponse?.data?.[0]?.sdp === undefined ) { peerConnection?.close?.(); this.#peerConnection = undefined; this?.log?.debug?.( 'WebRTC offer was not agreed with remote for uuid "%s". Response: %j', this.nest_google_device_uuid, homeFoyerResponse, ); this.setSourceState(Streamer.MESSAGE_TYPE.SOURCE_CLOSED, 'offer-rejected'); return; } // If the SDP answer contains a private/local candidate, then local access was granted. // Otherwise traffic will use the normal routed/remote path and we should continue sending // periodic stream extension requests to keep the session alive. let localAccessGranted = /a=candidate:.* (10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+|fd[0-9a-f]{2}:[0-9a-f:]+)/i.test( homeFoyerResponse.data[0].sdp || '', ) === true; this?.log?.debug?.( 'WebRTC offer agreed with remote for uuid "%s"%s', this.nest_google_device_uuid, localAccessGranted === true ? ' with local access granted' : '', ); // Track subscription callbacks only ingest RTP into our jitter/reorder buffers. // They do not emit media directly. this.#audioTransceiver?.onTrack?.subscribe?.((track) => { this.#handlePlaybackBegin(Streamer.MEDIA_TYPE.AUDIO); track.onReceiveRtp.subscribe((rtpPacket) => { if (track.codec.payloadType !== RTP_OPUS_AUDIO_PAYLOAD_TYPE) { // Not the payload type we expect for audio, so ignore return; } this.#handlePlaybackAudioPacket(rtpPacket); }); }); this.#videoTransceiver?.onTrack?.subscribe?.((track) => { this.#handlePlaybackBegin(Streamer.MEDIA_TYPE.VIDEO); track.onReceiveRtp.subscribe((rtpPacket) => { if (track.codec.payloadType !== RTP_H264_VIDEO_PAYLOAD_TYPE && track.codec.payloadType !== RTP_H264_VIDEO_RTX_PAYLOAD_TYPE) { // Not the payload types we expect for video, so ignore return; } this.#handlePlaybackVideoPacket(rtpPacket); }); }); this.#streamId = homeFoyerResponse.data[0].streamId; // connect() can overlap with close() during fast stream stop/reopen cycles. // If teardown replaced or cleared the active peer connection while this async // setup was in-flight, abort this stale connect attempt safely. if (this.#peerConnection !== peerConnection) { try { await peerConnection?.close?.(); } catch { // Empty } return; } await peerConnection?.setRemoteDescription?.({ type: 'answer', sdp: homeFoyerResponse.data[0].sdp, }); this?.log?.debug?.('Playback started from WebRTC for uuid "%s" with stream ID "%s"', this.nest_google_device_uuid, this.#streamId); // Monitor connection status. ICE "connected" means transport is ready, // not that media has actually started. Actual source readiness is promoted // later on first video packet arrival. peerConnection.iceConnectionStateChange.subscribe(() => { if (this.#peerConnection !== peerConnection) { return; } if (this.#closeInProgress === true) { return; } let state = peerConnection?.iceConnectionState; if (state === 'connected' || state === 'completed' || state === 'checking') { if (this.sourceState !== Streamer.MESSAGE_TYPE.SOURCE_CONNECTED) { this.setSourceState(Streamer.MESSAGE_TYPE.SOURCE_CONNECTED); } return; } if ( (state === 'failed' || state === 'disconnected' || (state === 'closed' && this.hasActiveStreams() === true)) && this.sourceState !== Streamer.MESSAGE_TYPE.SOURCE_CLOSING && this.sourceState !== Streamer.MESSAGE_TYPE.SOURCE_CLOSED && this.sourceState !== Streamer.MESSAGE_TYPE.SOURCE_RECONNECTING ) { this?.log?.debug?.('WebRTC ICE state "%s" for uuid "%s", requesting reconnect', state, this.nest_google_device_uuid); this.#requestReconnect('ice-' + state); if (this.hasActiveStreams() === true) { this.requestSourceClose(); } } }); // Periodically extend the active stream only when we do not have local access. // Local streams are expected to remain valid without needing explicit extend requests. if (localAccessGranted !== true) { this.#extendTimer = setInterval(async () => { if ( this.#grpcTransport !== undefined && this.sourceState === Streamer.MESSAGE_TYPE.SOURCE_READY && this.#streamId !== undefined && this.#googleHomeDeviceUUID !== undefined ) { let extendResponse = await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'JoinStream', { command: 'extend', deviceId: this.nest_google_device_uuid, streamId: this.#streamId, }); if (extendResponse?.data?.[0]?.streamExtensionStatus !== 'STATUS_STREAM_EXTENDED') { this?.log?.debug?.('Error occurred while requesting stream extension for uuid "%s"', this.nest_google_device_uuid); this.#requestReconnect('extend-failed'); this.requestSourceClose(); } } }, EXTEND_INTERVAL); } } async close() { if (this.#closeInProgress === true) { return; } this.#closeInProgress = true; let closingPeerConnection = this.#peerConnection; let closingStreamId = this.#streamId; let reconnectReason = this.#reconnectReason; let talkbackActive = this.#tracks?.talkback?.active === true; try { // Mark source as closing for a normal teardown so any in-flight playback // callbacks stop accepting new packets while shutdown is happening. // During reconnect we keep SOURCE_RECONNECTING so the lifecycle state // does not bounce backwards during transport teardown. if (this.#reconnectPending !== true) { this.setSourceState(Streamer.MESSAGE_TYPE.SOURCE_CLOSING); } // Stop timers first so we stop producing any new work immediately. clearInterval(this.#extendTimer); clearInterval(this.#stalledTimer); this.#extendTimer = undefined; this.#stalledTimer = undefined; this.#lastPacketAt = undefined; // Flush any pending video access unit before tearing state down. // Video is emitted frame-by-frame, so the last completed frame would otherwise // be lost if close occurs before another packet triggers a normal flush. this.#flushPendingVideoFrame(); // Clear media/talkback track state before closing remote transport. // This lets any in-flight callbacks naturally no-op while shutdown continues. this.#tracks = { audio: {}, video: {}, talkback: {} }; if (closingStreamId !== undefined && talkbackActive === true) { await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'SendTalkback', { googleDeviceId: { value: this.#googleHomeDeviceUUID, }, streamId: closingStreamId, command: 'COMMAND_STOP', }); } if (closingStreamId !== undefined) { this?.log?.debug?.('Notifying remote about closing connection for uuid "%s"', this.nest_google_device_uuid); // Tell remote to end the stream session await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'JoinStream', { command: 'end', deviceId: this.nest_google_device_uuid, streamId: closingStreamId, endStreamReason: 'REASON_USER_EXITED_SESSION', }); } try { await closingPeerConnection?.close?.(); } catch { // Empty } // NOTE: Do NOT release the gRPC client here. It should be reused across WebRTC reconnects // and only released during final shutdown in onShutdown(). Releasing it during // temporary disconnects causes in-flight requests to be canceled with "pending stream has been canceled". if (this.#streamId === closingStreamId) { this.#streamId = undefined; } if (this.#peerConnection === closingPeerConnection) { this.#peerConnection = undefined; this.#videoTransceiver = undefined; this.#audioTransceiver = undefined; } if (this.#reconnectPending === true) { // We have a reconnect pending, so reset the flag and attempt to reconnect. // We do this only after the current session has really closed to avoid racing // a new stream setup against a half-torn-down old connection. this.#reconnectPending = false; this.#reconnectReason = undefined; this?.log?.debug?.( 'Connection closed to WebRTC for uuid "%s", attempting reconnect%s', this.nest_google_device_uuid, typeof reconnectReason === 'string' && reconnectReason !== '' ? ' (' + reconnectReason + ')' : '', ); if (this.hasActiveStreams() === true) { this.requestSourceConnect().catch((error) => { this?.log?.debug?.('Error reconnecting WebRTC for uuid "%s": %s', this.nest_google_device_uuid, String(error)); }); return; } } if (this.hasActiveStreams() !== true && this.#reconnectPending !== true) { this.setSourceState(Streamer.MESSAGE_TYPE.SOURCE_CLOSED); } } finally { this.#closeInProgress = false; } } async onUpdate(deviceData) { if (typeof deviceData !== 'object') { return; } if ( typeof deviceData?.apiAccess?.oauth2 === 'string' && deviceData.apiAccess.oauth2 !== '' && deviceData.apiAccess.oauth2 !== this.token ) { // oauth2 token has changed, so update stored token. If we have an active connection. // This token will be used for the next API call that requires authentication and should succeed with the new token // Log this as a debug message only if we actually have active outputs // otherwise it can be normal for tokens to update when not streaming and would just be noise in the logs if (this.hasActiveStreams() === true) { this?.log?.debug?.( 'OAuth2 token has changed for uuid "%s" while webRTC session is active. Updating stored token.', this.nest_google_device_uuid, ); } this.token = deviceData.apiAccess.oauth2; } } async onShutdown() { await this.requestSourceClose(); // Gracefully close peer connection // Release the gRPC client only during final shutdown, not on temporary disconnects try { this.#grpcTransport?.release?.(); } catch { // Empty } } async sendTalkback(talkingBuffer) { if ( Buffer.isBuffer(talkingBuffer) !== true || this.#googleHomeDeviceUUID === undefined || this.#streamId === undefined || typeof this.#audioTransceiver?.sender?.sendRtp !== 'function' || typeof this.#tracks?.talkback !== 'object' ) { return; } let talk = this.#tracks.talkback; if (typeof talk !== 'object' || talk === null) { return; } // Start or send talkback audio if (talkingBuffer.length > 0) { // First packet for a new talkback session: // ask the remote device to enable talkback audio path. // // Important: // sendTalkback() may be called repeatedly while the async start request is still in-flight. // Use a separate "starting" flag so we only issue one COMMAND_START request. if (talk.active !== true) { if (talk.started === true) { return; } talk.started = true; let homeFoyerResponse = await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'SendTalkback', { googleDeviceId: { value: this.#googleHomeDeviceUUID }, streamId: this.#streamId, command: 'COMMAND_START', }); talk.started = false; if (homeFoyerResponse?.status !== 0) { this?.log?.debug?.('Error starting talkback for uuid "%s"', this.nest_google_device_uuid); talk.active = undefined; return; } talk.active = true; this?.log?.debug?.('Talking started on uuid "%s"', this.nest_google_device_uuid); } if (talk.active !== true) { return; } // Initialise RTP state if not already done. // We need this to build correct RTP headers for the talkback audio packets we send. if (typeof talk.rtp !== 'object' || talk.rtp === null) { talk.rtp = {}; } if (typeof talk.rtp.sequenceNumber !== 'number') { talk.rtp.sequenceNumber = 0; } if (typeof talk.rtp.timestamp !== 'number') { // RTP timestamps are sample-based, not wallclock talk.rtp.timestamp = Math.floor(Math.random() * 0xffffffff); } // Build RTP packet with appropriate headers and payload let header = new werift.RtpHeader(); header.ssrc = this.#audioTransceiver.sender.ssrc; header.payloadType = talk.id; header.sequenceNumber = talk.rtp.sequenceNumber++ & RTP_SEQUENCE_MASK; header.timestamp = talk.rtp.timestamp >>> 0; header.marker = true; header.payloadOffset = RTP_PACKET_HEADER_SIZE; let packet = new werift.RtpPacket(header, talkingBuffer); this.#audioTransceiver.sender.sendRtp(packet.serialize()); // Increment timestamp for next packet (monotonic RTP clock) // 20ms @ 48kHz = 960 samples per packet talk.rtp.timestamp = (talk.rtp.timestamp + Math.round((talk.sampleRate * talk.packetTime) / 1000)) >>> 0; return; } // Empty buffer means talkback session has ended if (talkingBuffer.length === 0 && (talk.active === true || talk.started === true)) { // If a start request is still in-flight, do not issue stop yet. // We'll just reset local state and let the next session start cleanly. if (talk.started === true) { talk.started = false; talk.active = undefined; talk.rtp = undefined; return; } let homeFoyerResponse = await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'SendTalkback', { googleDeviceId: { value: this.#googleHomeDeviceUUID }, streamId: this.#streamId, command: 'COMMAND_STOP', }); if (homeFoyerResponse?.status !== 0) { this?.log?.debug?.('Error stopping talkback for uuid "%s"', this.nest_google_device_uuid); } else { this?.log?.debug?.('Talking ended on uuid "%s"', this.nest_google_device_uuid); } // Reset state ready for next session talk.active = undefined; talk.started = false; talk.rtp = undefined; } } #handlePlaybackBegin(mediaType) { let video = undefined; let audio = undefined; if (this.sourceState === Streamer.MESSAGE_TYPE.SOURCE_CLOSING || this.sourceState === Streamer.MESSAGE_TYPE.SOURCE_CLOSED) { return; } if (typeof this.#tracks !== 'object' || this.#tracks === null) { this.#tracks = {}; } if (mediaType === Streamer.MEDIA_TYPE.VIDEO) { if (typeof this.#tracks.video !== 'object' || this.#tracks.video === null) { this.#tracks.video = {}; } video = this.#tracks.video; if (typeof video.id !== 'number') { video.id = RTP_H264_VIDEO_PAYLOAD_TYPE; } if (typeof video.rtxId !== 'number') { video.rtxId = RTP_H264_VIDEO_RTX_PAYLOAD_TYPE; } if (typeof video.rtxSsrc !== 'number') { video.rtxSsrc = undefined; } if (typeof video.codec !== 'string') { video.codec = Streamer.CODEC_TYPE.H264; } if (typeof video.sampleRate !== 'number') { video.sampleRate = 90000; } // RTP packet tracking for video timing/order checks if (typeof video.rtp !== 'object' || video.rtp === null) { video.rtp = { lastSequence: undefined, lastTimestamp: undefined, lastCalculatedTimestamp: undefined, lastEmittedTimestamp: undefined, }; } // H264 frame assembly and cached parameter sets if (typeof video.h264 !== 'object' || video.h264 === null) { video.h264 = { fuParts: [], fuBytes: 0, fuNalType: 0, fuRtpTimestamp: undefined, fuFirstPacketTime: undefined, lastSPS: undefined, lastPPS: undefined, lastIDR: undefined, lastSpsEmitTime: undefined, pendingParts: [], pendingRtpTimestamp: undefined, pendingFirstPacketTime: undefined, pendingKeyFrame: false, pendingBytes: 0, pendingHasVcl: false, pendingMarkerSeen: false, pendingCorrupt: false, }; } // Output timestamp tracking used when handing frames to Streamer if (typeof video.output !== 'object' || video.output === null) { video.output = { lastTimestamp: undefined, }; } if (typeof video.lastPLITime === 'undefined') { video.lastPLITime = undefined; } if (typeof video.lastNACKTime === 'undefined') { video.lastNACKTime = undefined; } if (typeof video.lastIDRTime === 'undefined') { video.lastIDRTime = undefined; } if (typeof video.health !== 'object' || video.health === null) { video.health = { state: 'STABLE', events: [], cleanScore: 0, lastCleanKeyframeTime: undefined, suppressDeltas: false, lastSuppressedLogTime: undefined, }; } if (typeof video.deltaAudit !== 'object' || video.deltaAudit === null) { video.deltaAudit = { hasAcceptedKeyframe: false, lastAcceptedKeyframeTime: undefined, deltaEmittedSinceKeyframe: 0, deltaFuStartsSinceKeyframe: 0, deltaFuCompletesSinceKeyframe: 0, deltaFuGraceDefers: 0, deltaFuAbandonedTsSwitch: 0, deltaPendingAbandonedTsSwitch: 0, deltaEarlyAbandon: 0, auditLegendLogged: false, }; } // Ask once for a startup keyframe, then let the source continue naturally. this.#sendVideoPLI('startup'); this.#refreshStallTimer(); return; } if (mediaType === Streamer.MEDIA_TYPE.AUDIO) { if (typeof this.#tracks.audio !== 'object' || this.#tracks.audio === null) { this.#tracks.audio = {}; } audio = this.#tracks.audio; if (typeof audio.id !== 'number') { audio.id = RTP_OPUS_AUDIO_PAYLOAD_TYPE; } if (typeof audio.codec !== 'string') { audio.codec = Streamer.CODEC_TYPE.OPUS; } if (typeof audio.sampleRate !== 'number') { audio.sampleRate = 48000; } if (typeof audio.channels !== 'number') { audio.channels = 2; } if (typeof audio.packetTime !== 'number') { audio.packetTime = 20; } // RTP packet tracking for audio timing/order checks if (typeof audio.rtp !== 'object' || audio.rtp === null) { audio.rtp = { lastSequence: undefined, lastTimestamp: undefined, }; } // Output timestamp tracking used when handing PCM frames to Streamer if (typeof audio.output !== 'object' || audio.output === null) { audio.output = { lastTimestamp: undefined, }; } if (typeof audio.lastTimingClampLogTime !== 'number') { audio.lastTimingClampLogTime = undefined; } if (typeof audio.lastDecodeFallbackLogTime !== 'number') { audio.lastDecodeFallbackLogTime = undefined; } this.#refreshStallTimer(); } } #handlePlaybackVideoPacket(rtpPacket) { if (this.sourceState === Streamer.MESSAGE_TYPE.SOURCE_CLOSING || this.sourceState === Streamer.MESSAGE_TYPE.SOURCE_CLOSED) { // We are closing or closed, so ignore any incoming packets. This can happen when remote is still sending // before we finish tearing down the connection, but we do not want to process any new packets at this point. return; } let fuHeader = 0; let fuStart = false; let fuEnd = false; let fuNalType = 0; let fuNalHeader = 0; let fragment = undefined; let part = undefined; let stapOffset = 0; let stapLength = 0; let stapNal = undefined; let stapNalType = 0; let seqDelta = 0; let rtxOriginalSequence = 0; let isRtxPacket = false; let acceptAsRecoveredRtx = false; let pendingAgeMs = undefined; let fuAgeMs = undefined; let pendingTsDeltaTicks = 0; let pendingTsWrapCandidate = false; let fuTsDeltaTicks = 0; let fuTsWrapCandidate = false; let incomingNalType = 0; let incomingFuHeader = 0; let incomingFuStart = false; let incomingFuNalType = 0; let incomingIsIdrFuStart = false; let pendingPartCount = 0; let pendingByteCount = 0; let pendingHasContent = false; // Ensure we have a valid RTP packet with a payload before processing video data if ( typeof rtpPacket !== 'object' || rtpPacket === null || typeof rtpPacket?.header !== 'object' || rtpPacket.header === null || Buffer.isBuffer(rtpPacket?.payload) !== true || rtpPacket.payload.length === 0 ) { return; } // Pull out the RTP header details we use repeatedly below let header = rtpPacket.header; let payload = rtpPacket.payload; let marker = header.marker === true; let sequenceNumber = Number.isInteger(header.sequenceNumber) === true ? header.sequenceNumber : 0; let rtpTimestamp = Number.isInteger(header.timestamp) === true ? header.timestamp >>> 0 : 0; let payloadType = Number.isInteger(header.payloadType) === true ? header.payloadType : undefined; let ssrc = Number.isInteger(header.ssrc) === true ? header.ssrc >>> 0 : undefined; // Ensure playback state exists even if packets arrive before track open handling finishes if ( typeof this.#tracks?.video !== 'object' || this.#tracks.video === null || typeof this.#tracks.video?.h264 !== 'object' || this.#tracks.video.h264 === null || typeof this.#tracks.video?.rtp !== 'object' || this.#tracks.video.rtp === null || typeof this.#tracks.video?.output !== 'object' || this.#tracks.video.output === null ) { this.#handlePlaybackBegin(Streamer.MEDIA_TYPE.VIDEO); } let video = this.#tracks.video; let h264 = video.h264; let videoRtp = video.rtp; let deltaAudit = video.deltaAudit; isRtxPacket = typeof payloadType === 'number' && payloadType === video.rtxId; if (typeof deltaAudit !== 'object' || deltaAudit === null) { deltaAudit = { hasAcceptedKeyframe: false, lastAcceptedKeyframeTime: undefined, deltaEmittedSinceKeyframe: 0, deltaFuStartsSinceKeyframe: 0, deltaFuCompletesSinceKeyframe: 0, deltaFuGraceDefers: 0, deltaFuAbandonedTsSwitch: 0, deltaPendingAbandonedTsSwitch: 0, deltaEarlyAbandon: 0, auditLegendLogged: false, }; video.deltaAudit = deltaAudit; } // Track primary video SSRC separately from RTX SSRC. Google can send retransmissions // on a distinct SSRC, and we do not want that to disturb primary stream locking. if (isRtxPacket !== true) { if (typeof video.ssrc !== 'number' && typeof ssrc === 'number') { video.ssrc = ssrc; } if (typeof video.ssrc === 'number' && typeof ssrc === 'number' && ssrc !== video.ssrc) { return; } } // Rebuild original H264 payload from RFC4588 RTX packets. // RTX payload starts with 2-byte original sequence number followed by original RTP payload. if (isRtxPacket === true) { if (typeof video.rtxSsrc !== 'number' && typeof ssrc === 'number') { video.rtxSsrc = ssrc; } if (typeof video.rtxSsrc === 'number' && typeof ssrc === 'number' && ssrc !== video.rtxSsrc) { return; } if (Buffer.isBuffer(payload) !== true || payload.length < 3) { return; } // We need the primary SSRC lock before reinjecting RTX into normal ordering logic. if (typeof video.ssrc !== 'number') { return; } rtxOriginalSequence = payload.readUInt16BE(0); payload = payload.subarray(2); sequenceNumber = rtxOriginalSequence; payloadType = video.id; ssrc = video.ssrc; acceptAsRecoveredRtx = true; } if (typeof payloadType === 'number' && payloadType !== video.id) { return; } // Drop duplicate or clearly late/out-of-order packets before they touch assembly state. // This mirrors the protection already used on audio and avoids duplicate fragments or // old retransmits corrupting pending H264 access units. if (acceptAsRecoveredRtx !== true) { if (typeof videoRtp.lastSequence === 'number') { seqDelta = (sequenceNumber - videoRtp.lastSequence + RTP_SEQUENCE_WRAP) % RTP_SEQUENCE_WRAP; if (seqDelta === 0 || seqDelta > RTP_SEQUENCE_WRAP / 2) { return; } } videoRtp.lastSequence = sequenceNumber; } // Any valid incoming video RTP packet means the playback path is still alive this.#refreshStallTimer(); // Normalise pending frame state so packets for the same RTP timestamp can be grouped together if (Array.isArray(h264.pendingParts) !== true) { h264.pendingParts = []; } if (typeof h264.pendingBytes !== 'number') { h264.pendingBytes = 0; } if (typeof h264.pendingHasVcl !== 'boolean') { h264.pendingHasVcl = false; } if (typeof h264.pendingMarkerSeen !== 'boolean') { h264.pendingMarkerSeen = false; } if (typeof h264.pendingCorrupt !== 'boolean') { h264.pendingCorrupt = false; } if (typeof h264.pendingKeyFrame !== 'boolean') { h264.pendingKeyFrame = false; } if (typeof h264.pendingFirstPacketTime !== 'number') { h264.pendingFirstPacketTime = undefined; } // Normalise FU-A assembly state used for fragmented H264 NAL units if (Array.isArray(h264.fuParts) !== true) { h264.fuParts = []; } if (typeof h264.fuBytes !== 'number') { h264.fuBytes = 0; } if (typeof h264.fuNalType !== 'number') { h264.fuNalType = 0; } if (typeof h264.fuRtpTimestamp !== 'number') { h264.fuRtpTimestamp = undefined; } if (typeof h264.fuFirstPacketTime !== 'number') { h264.fuFirstPacketTime = undefined; } // Peek at incoming packet type so keyframe FU-A starts can preempt delta grace. incomingNalType = payload[0] & 0x1f; if (incomingNalType === Streamer.H264NALUS.TYPES.FU_A && payload.length >= 2) { incomingFuHeader = payload[1]; incomingFuStart = (incomingFuHeader & 0x80) === 0x80; incomingFuNalType = incomingFuHeader & 0x1f; incomingIsIdrFuStart = incomingFuStart === true && incomingFuNalType === Streamer.H264NALUS.TYPES.IDR; } // Tiny grace for young non-keyframe FU-A units: // ignore a newer timestamp briefly so we do not abandon a nearly-finished delta FU // due to slight packet reordering/timing skew. if ( typeof h264.fuRtpTimestamp === 'number' && h264.fuRtpTimestamp !== rtpTimestamp && h264.fuNalType === Streamer.H264NALUS.TYPES.SLICE_NON_IDR ) { fuAgeMs = typeof h264.fuFirstPacketTime === 'number' ? Date.now() - h264.fuFirstPacketTime : undefined; if (Number.isFinite(fuAgeMs) === true && fuAgeMs <= DELTA_FU_SWITCH_GRACE_MS) { if (incomingIsIdrFuStart !== true) { deltaAudit.deltaFuGraceDefers++; return; } } } // If a new RTP timestamp arrives while a previous pending frame is still open, flush it if complete // Otherwise drop it as incomplete and start building the new frame instead if (acceptAsRecoveredRtx === true && typeof h264.pendingRtpTimestamp === 'number' && h264.pendingRtpTimestamp !== rtpTimestamp) { // Recovered RTX for an already-closed or superseded access unit is not useful here. // Drop it rather than disturbing current frame assembly state. return; } if (typeof h264.pendingRtpTimestamp === 'number' && h264.pendingRtpTimestamp !== rtpTimestamp) { pendingTsDeltaTicks = (rtpTimestamp - h264.pendingRtpTimestamp + RTP_TIMESTAMP_MASK) % RTP_TIMESTAMP_MASK; pendingTsWrapCandidate = h264.pendingRtpTimestamp > rtpTimestamp && pendingTsDeltaTicks < video.sampleRate * 2; pendingPartCount = Array.isArray(h264.pendingParts) === true ? h264.pendingParts.length : 0; pendingByteCount = Number.isFinite(h264.pendingBytes) === true ? h264.pendingBytes : 0; pendingHasContent = pendingPartCount > 0 || pendingByteCount > 0; if (pendingHasContent !== true) { // Timestamp changed with no buffered access-unit payload: reset silently. this.#resetPendingVideoFrame(); } else if (Array.isArray(h264.pendingParts) === true && h264.pendingParts.length > 0 && h264.pendingMarkerSeen === true) { this.#flushPendingVideoFrame(); } else { pendingAgeMs = typeof h264.pendingFirstPacketTime === 'number' ? Date.now() - h264.pendingFirstPacketTime : undefined; if (h264.pendingKeyFrame !== true) { deltaAudit.deltaPendingAbandonedTsSwitch++; if (Number.isFinite(pendingAgeMs) === true && pendingAgeMs <= 80) { deltaAudit.deltaEarlyAbandon++; } } this?.log?.debug?.( 'Drop incomplete pending video uuid "%s": oldTs=%s newTs=%s deltaTicks=%s wrapCandidate=%s parts=%s bytes=%s ageMs=%s marker=%s', this.nest_google_device_uuid, h264.pendingRtpTimestamp, rtpTimestamp, pendingTsDeltaTicks, pendingTsWrapCandidate === true ? 'true' : 'false', pendingPartCount, pendingByteCount, pendingAgeMs, h264.pendingMarkerSeen === true ? 'true' : 'false', ); if (h264.pendingKeyFrame === true || (Number.isFinite(pendingAgeMs) === true && pendingAgeMs >= 300)) { this.#sendVideoPLI('pending-incomplete'); this.#recordVideoHealthEvent('pending-incomplete'); } this.#resetPendingVideoFrame(); } } // If a fragmented FU-A frame is still in progress but a new RTP timestamp arrives, drop the old fragment set if (typeof h264.fuRtpTimestamp === 'number' && h264.fuRtpTimestamp !== rtpTimestamp) { fuTsDeltaTicks = (rtpTimestamp - h264.fuRtpTimestamp + RTP_TIMESTAMP_MASK) % RTP_TIMESTAMP_MASK; fuTsWrapCandidate = h264.fuRtpTimestamp > rtpTimestamp && fuTsDeltaTicks < video.sampleRate * 2; fuAgeMs = typeof h264.fuFirstPacketTime === 'number' ? Date.now() - h264.fuFirstPacketTime : undefined; if (h264.fuNalType === Streamer.H264NALUS.TYPES.SLICE_NON_IDR) { deltaAudit.deltaFuAbandonedTsSwitch++; if (Number.isFinite(fuAgeMs) === true && fuAgeMs <= 80) { deltaAudit.deltaEarlyAbandon++; } } this?.log?.debug?.( 'Drop incomplete FU-A uuid "%s": oldTs=%s newTs=%s deltaTicks=%s wrapCandidate=%s nalType=%s parts=%s bytes=%s ageMs=%s', this.nest_google_device_uuid, h264.fuRtpTimestamp, rtpTimestamp, fuTsDeltaTicks, fuTsWrapCandidate === true ? 'true' : 'false', h264.fuNalType, Array.isArray(h264.fuParts) === true ? h264.fuParts.length : 0, Number.isFinite(h264.fuBytes) === true ? h264.fuBytes : 0, fuAgeMs, ); if (h264.fuNalType === Streamer.H264NALUS.TYPES.IDR || (Number.isFinite(fuAgeMs) === true && fuAgeMs >= 600)) { this.#sendVideoPLI('fu-incomplete'); this.#recordVideoHealthEvent('fu-incomplete'); } this.#resetFragmentedVideoFrame(); } // Initialise the pending frame timestamp from the first packet we see for this frame if (typeof h264.pendingRtpTimestamp !== 'number') { h264.pendingRtpTimestamp = rtpTimestamp; h264.pendingFirstPacketTime = Date.now(); } let nalHeader = payload[0]; let nalType = nalHeader & 0x1f; let nri = nalHeader & 0x60; // Single NAL units can be appended directly to the pending frame if (nalType > 0 && nalType < 24) { part = Buffer.allocUnsafe(Streamer.H264NALUS.START_CODE.length + payload.length); Streamer.H264NALUS.START_CODE.copy(part, 0); payload.copy(part, Streamer.H264NALUS.START_CODE.length); h264.pendingParts.push(part); h264.pendingBytes += part.length; if (nalType === Streamer.H264NALUS.TYPES.SPS) { h264.lastSPS = Buffer.from(payload); } if (nalType === Streamer.H264NALUS.TYPES.PPS) { h264.lastPPS = Buffer.from(payload); } if (nalType === Streamer.H264NALUS.TYPES.IDR) { h264.pendingKeyFrame = true; h264.pendingHasVcl = true; h264.lastIDR = Buffer.from(payload); } if (nalType === Streamer.H264NALUS.TYPES.SLICE_NON_IDR) { h264.pendingHasVcl = true; } // Marker means this RTP packet finishes the access unit, so flush the frame now if (marker === true) { h264.pendingMarkerSeen = true; this.#flushPendingVideoFrame(); } return; } // STAP-A contains multiple complete NAL units in a single RTP packet if (nalType === Streamer.H264NALUS.TYPES.STAP_A) { stapOffset = 1; while (stapOffset + 2 <= payload.length) { stapLength = payload.readUInt16BE(stapOffset); stapOffset += 2; if (stapLength <= 0 || stapOffset + stapLength > payload.length) { h264.pendingCorrupt = true; break; } stapNal = payload.subarray(stapOffset, stapOffset + stapLength); stapOffset += stapLength; if (Buffer.isBuffer(stapNal) !== true || stapNal.length === 0) { continue; } part = Buffer.allocUnsafe(Streamer.H264NALUS.START_CODE.length + stapNal.length); Streamer.H264NALUS.START_CODE.copy(part, 0); stapNal.copy(part, Streamer.H264NALUS.START_CODE.length); h264.pendingParts.push(part); h264.pendingBytes += part.length; stapNalType = stapNal[0] & 0x1f; if (stapNalType === Streamer.H264NALUS.TYPES.SPS) { h264.lastSPS = Buffer.from(stapNal); } if (stapNalType === Streamer.H264NALUS.TYPES.PPS) { h264.lastPPS = Buffer.from(stapNal); } if (stapNalType === Streamer.H264NALUS.TYPES.IDR) { h264.pendingKeyFrame = true; h264.pendingHasVcl = true; h264.lastIDR = Buffer.from(stapNal); } if (stapNalType === Streamer.H264NALUS.TYPES.SLICE_NON_IDR) { h264.pendingHasVcl = true; } } // Marker means this packet completed the frame payload for this timestamp if (marker === true) { h264.pendingMarkerSeen = true; this.#flushPendingVideoFrame(); } return; } // FU-A carries one large NAL unit split across multiple RTP packets if (nalType === Streamer.H264NALUS.TYPES.FU_A) { if (payload.length < 2) { h264.pendingCorrupt = true;