UNPKG

hap-nodejs

Version:

HAP-NodeJS is a Node.js implementation of HomeKit Accessory Server.

658 lines 30.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CameraController = exports.CameraControllerEvents = exports.ResourceRequestReason = void 0; const tslib_1 = require("tslib"); const crypto_1 = tslib_1.__importDefault(require("crypto")); const debug_1 = tslib_1.__importDefault(require("debug")); const events_1 = require("events"); const camera_1 = require("../camera"); const Characteristic_1 = require("../Characteristic"); const datastream_1 = require("../datastream"); const Service_1 = require("../Service"); const hapStatusError_1 = require("../util/hapStatusError"); const debug = (0, debug_1.default)("HAP-NodeJS:Camera:Controller"); /** * @group Camera */ var ResourceRequestReason; (function (ResourceRequestReason) { /** * The reason describes periodic resource requests. * In the example of camera image snapshots those are the typical preview images every 10 seconds. */ ResourceRequestReason[ResourceRequestReason["PERIODIC"] = 0] = "PERIODIC"; /** * The resource request is the result of some event. * In the example of camera image snapshots, requests are made due to e.g. a motion event or similar. */ ResourceRequestReason[ResourceRequestReason["EVENT"] = 1] = "EVENT"; })(ResourceRequestReason || (exports.ResourceRequestReason = ResourceRequestReason = {})); /** * @group Camera */ var CameraControllerEvents; (function (CameraControllerEvents) { /** * Emitted when the mute state or the volume changed. The Apple Home App typically does not set those values * except the mute state. When you adjust the volume in the Camera view it will reset the muted state if it was set previously. * The value of volume has nothing to do with the volume slider in the Camera view of the Home app. */ CameraControllerEvents["MICROPHONE_PROPERTIES_CHANGED"] = "microphone-change"; /** * Emitted when the mute state or the volume changed. The Apple Home App typically does not set those values * except the mute state. When you unmute the device microphone it will reset the mute state if it was set previously. */ CameraControllerEvents["SPEAKER_PROPERTIES_CHANGED"] = "speaker-change"; })(CameraControllerEvents || (exports.CameraControllerEvents = CameraControllerEvents = {})); /** * Everything needed to expose a HomeKit Camera. * * @group Camera */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging class CameraController extends events_1.EventEmitter { static STREAM_MANAGEMENT = "streamManagement"; // key to index all RTPStreamManagement services stateChangeDelegate; streamCount; delegate; streamingOptions; /** * **Temporary** storage for {@link CameraRecordingOptions} and {@link CameraRecordingDelegate}. * This property is reset to `undefined` after the CameraController was fully initialized. * You can still access those values via the {@link CameraController.recordingManagement}. */ recording; /** * Temporary storage for the sensor option. */ sensorOptions; legacyMode = false; /** * @private */ streamManagements = []; /** * The {@link RecordingManagement} which is responsible for handling HomeKit Secure Video. * This property is only present if recording was configured. */ recordingManagement; microphoneService; speakerService; microphoneMuted = false; microphoneVolume = 100; speakerMuted = false; speakerVolume = 100; motionService; motionServiceExternallySupplied = false; occupancyService; occupancyServiceExternallySupplied = false; constructor(options, legacyMode = false) { super(); this.streamCount = Math.max(1, options.cameraStreamCount || 1); this.delegate = options.delegate; this.streamingOptions = options.streamingOptions; this.recording = options.recording; this.sensorOptions = options.sensors; this.legacyMode = legacyMode; // legacy mode will prevent from Microphone and Speaker services to get created to avoid collisions } /** * @private */ controllerId() { return "camera" /* DefaultControllerType.CAMERA */; } // ----------------------------------- STREAM API ------------------------------------ /** * Call this method if you want to forcefully suspend an ongoing streaming session. * This would be adequate if the rtp server or media encoding encountered an unexpected error. * * @param sessionId - id of the current ongoing streaming session */ forceStopStreamingSession(sessionId) { this.streamManagements.forEach(management => { if (management.sessionIdentifier === sessionId) { management.forceStop(); } }); } static generateSynchronisationSource() { const ssrc = crypto_1.default.randomBytes(4); // range [-2.14748e+09 - 2.14748e+09] ssrc[0] = 0; return ssrc.readInt32BE(0); } // ----------------------------- MICROPHONE/SPEAKER API ------------------------------ setMicrophoneMuted(muted = true) { if (!this.microphoneService) { return; } this.microphoneMuted = muted; this.microphoneService.updateCharacteristic(Characteristic_1.Characteristic.Mute, muted); } setMicrophoneVolume(volume) { if (!this.microphoneService) { return; } this.microphoneVolume = volume; this.microphoneService.updateCharacteristic(Characteristic_1.Characteristic.Volume, volume); } setSpeakerMuted(muted = true) { if (!this.speakerService) { return; } this.speakerMuted = muted; this.speakerService.updateCharacteristic(Characteristic_1.Characteristic.Mute, muted); } setSpeakerVolume(volume) { if (!this.speakerService) { return; } this.speakerVolume = volume; this.speakerService.updateCharacteristic(Characteristic_1.Characteristic.Volume, volume); } emitMicrophoneChange() { this.emit("microphone-change" /* CameraControllerEvents.MICROPHONE_PROPERTIES_CHANGED */, this.microphoneMuted, this.microphoneVolume); } emitSpeakerChange() { this.emit("speaker-change" /* CameraControllerEvents.SPEAKER_PROPERTIES_CHANGED */, this.speakerMuted, this.speakerVolume); } // ----------------------------------------------------------------------------------- /** * @private */ constructServices() { for (let i = 0; i < this.streamCount; i++) { const rtp = new camera_1.RTPStreamManagement(i, this.streamingOptions, this.delegate, undefined, this.rtpStreamManagementDisabledThroughOperatingMode.bind(this)); this.streamManagements.push(rtp); } if (!this.legacyMode && this.streamingOptions.audio) { // In theory the Microphone Service is a necessity. In practice, it's not. lol. // So we just add it if the user wants to support audio this.microphoneService = new Service_1.Service.Microphone("", ""); this.microphoneService.setCharacteristic(Characteristic_1.Characteristic.Volume, this.microphoneVolume); if (this.streamingOptions.audio.twoWayAudio) { this.speakerService = new Service_1.Service.Speaker("", ""); this.speakerService.setCharacteristic(Characteristic_1.Characteristic.Volume, this.speakerVolume); } } if (this.recording) { this.recordingManagement = new camera_1.RecordingManagement(this.recording.options, this.recording.delegate, this.retrieveEventTriggerOptions()); } if (this.sensorOptions?.motion) { if (typeof this.sensorOptions.motion === "boolean") { this.motionService = new Service_1.Service.MotionSensor("", ""); } else { this.motionService = this.sensorOptions.motion; this.motionServiceExternallySupplied = true; } this.motionService.setCharacteristic(Characteristic_1.Characteristic.StatusActive, true); this.recordingManagement?.recordingManagementService.addLinkedService(this.motionService); } if (this.sensorOptions?.occupancy) { if (typeof this.sensorOptions.occupancy === "boolean") { this.occupancyService = new Service_1.Service.OccupancySensor("", ""); } else { this.occupancyService = this.sensorOptions.occupancy; this.occupancyServiceExternallySupplied = true; } this.occupancyService.setCharacteristic(Characteristic_1.Characteristic.StatusActive, true); this.recordingManagement?.recordingManagementService.addLinkedService(this.occupancyService); } const serviceMap = { microphone: this.microphoneService, speaker: this.speakerService, motionService: !this.motionServiceExternallySupplied ? this.motionService : undefined, occupancyService: !this.occupancyServiceExternallySupplied ? this.occupancyService : undefined, }; if (this.recordingManagement) { serviceMap.cameraEventRecordingManagement = this.recordingManagement.recordingManagementService; serviceMap.cameraOperatingMode = this.recordingManagement.operatingModeService; serviceMap.dataStreamTransportManagement = this.recordingManagement.dataStreamManagement.getService(); } this.streamManagements.forEach((management, index) => { serviceMap[CameraController.STREAM_MANAGEMENT + index] = management.getService(); }); this.recording = undefined; this.sensorOptions = undefined; return serviceMap; } /** * @private */ initWithServices(serviceMap) { const result = this._initWithServices(serviceMap); if (result.updated) { // serviceMap must only be returned if anything actually changed return result.serviceMap; } } _initWithServices(serviceMap) { let modifiedServiceMap = false; // eslint-disable-next-line no-constant-condition for (let i = 0; true; i++) { const streamManagementService = serviceMap[CameraController.STREAM_MANAGEMENT + i]; if (i < this.streamCount) { const operatingModeClosure = this.rtpStreamManagementDisabledThroughOperatingMode.bind(this); if (streamManagementService) { // normal init this.streamManagements.push(new camera_1.RTPStreamManagement(i, this.streamingOptions, this.delegate, streamManagementService, operatingModeClosure)); } else { // stream count got bigger, we need to create a new service const management = new camera_1.RTPStreamManagement(i, this.streamingOptions, this.delegate, undefined, operatingModeClosure); this.streamManagements.push(management); serviceMap[CameraController.STREAM_MANAGEMENT + i] = management.getService(); modifiedServiceMap = true; } } else { if (streamManagementService) { // stream count got reduced, we need to remove old service delete serviceMap[CameraController.STREAM_MANAGEMENT + i]; modifiedServiceMap = true; } else { break; // we finished counting, and we got no saved service; we are finished } } } // MICROPHONE if (!this.legacyMode && this.streamingOptions.audio) { // microphone should be present if (serviceMap.microphone) { this.microphoneService = serviceMap.microphone; } else { // microphone wasn't created yet => create a new one this.microphoneService = new Service_1.Service.Microphone("", ""); this.microphoneService.setCharacteristic(Characteristic_1.Characteristic.Volume, this.microphoneVolume); serviceMap.microphone = this.microphoneService; modifiedServiceMap = true; } } else if (serviceMap.microphone) { // microphone service supplied, though settings seemed to have changed // we need to remove it delete serviceMap.microphone; modifiedServiceMap = true; } // SPEAKER if (!this.legacyMode && this.streamingOptions.audio?.twoWayAudio) { // speaker should be present if (serviceMap.speaker) { this.speakerService = serviceMap.speaker; } else { // speaker wasn't created yet => create a new one this.speakerService = new Service_1.Service.Speaker("", ""); this.speakerService.setCharacteristic(Characteristic_1.Characteristic.Volume, this.speakerVolume); serviceMap.speaker = this.speakerService; modifiedServiceMap = true; } } else if (serviceMap.speaker) { // speaker service supplied, though settings seemed to have changed // we need to remove it delete serviceMap.speaker; modifiedServiceMap = true; } // RECORDING if (this.recording) { const eventTriggers = this.retrieveEventTriggerOptions(); // RECORDING MANAGEMENT if (serviceMap.cameraEventRecordingManagement && serviceMap.cameraOperatingMode && serviceMap.dataStreamTransportManagement) { this.recordingManagement = new camera_1.RecordingManagement(this.recording.options, this.recording.delegate, eventTriggers, { recordingManagement: serviceMap.cameraEventRecordingManagement, operatingMode: serviceMap.cameraOperatingMode, dataStreamManagement: new datastream_1.DataStreamManagement(serviceMap.dataStreamTransportManagement), }); } else { this.recordingManagement = new camera_1.RecordingManagement(this.recording.options, this.recording.delegate, eventTriggers); serviceMap.cameraEventRecordingManagement = this.recordingManagement.recordingManagementService; serviceMap.cameraOperatingMode = this.recordingManagement.operatingModeService; serviceMap.dataStreamTransportManagement = this.recordingManagement.dataStreamManagement.getService(); modifiedServiceMap = true; } } else { if (serviceMap.cameraEventRecordingManagement) { delete serviceMap.cameraEventRecordingManagement; modifiedServiceMap = true; } if (serviceMap.cameraOperatingMode) { delete serviceMap.cameraOperatingMode; modifiedServiceMap = true; } if (serviceMap.dataStreamTransportManagement) { delete serviceMap.dataStreamTransportManagement; modifiedServiceMap = true; } } // MOTION SENSOR if (this.sensorOptions?.motion) { if (typeof this.sensorOptions.motion === "boolean") { if (serviceMap.motionService) { this.motionService = serviceMap.motionService; } else { // it could be the case that we previously had a manually supplied motion service // at this point we can't remove the iid from the list of linked services from the recording management! this.motionService = new Service_1.Service.MotionSensor("", ""); } } else { this.motionService = this.sensorOptions.motion; this.motionServiceExternallySupplied = true; if (serviceMap.motionService) { // motion service previously supplied as bool option this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.motionService); delete serviceMap.motionService; modifiedServiceMap = true; } } this.motionService.setCharacteristic(Characteristic_1.Characteristic.StatusActive, true); this.recordingManagement?.recordingManagementService.addLinkedService(this.motionService); } else { if (serviceMap.motionService) { this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.motionService); delete serviceMap.motionService; modifiedServiceMap = true; } } // OCCUPANCY SENSOR if (this.sensorOptions?.occupancy) { if (typeof this.sensorOptions.occupancy === "boolean") { if (serviceMap.occupancyService) { this.occupancyService = serviceMap.occupancyService; } else { // it could be the case that we previously had a manually supplied occupancy service // at this point we can't remove the iid from the list of linked services from the recording management! this.occupancyService = new Service_1.Service.OccupancySensor("", ""); } } else { this.occupancyService = this.sensorOptions.occupancy; this.occupancyServiceExternallySupplied = true; if (serviceMap.occupancyService) { // occupancy service previously supplied as bool option this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.occupancyService); delete serviceMap.occupancyService; modifiedServiceMap = true; } } this.occupancyService.setCharacteristic(Characteristic_1.Characteristic.StatusActive, true); this.recordingManagement?.recordingManagementService.addLinkedService(this.occupancyService); } else { if (serviceMap.occupancyService) { this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.occupancyService); delete serviceMap.occupancyService; modifiedServiceMap = true; } } if (this.migrateFromDoorbell(serviceMap)) { modifiedServiceMap = true; } this.recording = undefined; this.sensorOptions = undefined; return { serviceMap: serviceMap, updated: modifiedServiceMap, }; } // overwritten in DoorbellController (to avoid cyclic dependencies, I hate typescript for that) migrateFromDoorbell(serviceMap) { if (serviceMap.doorbell) { // See NOTICE in DoorbellController delete serviceMap.doorbell; return true; } return false; } retrieveEventTriggerOptions() { if (!this.recording) { return new Set(); } const triggerOptions = new Set(); if (this.recording.options.overrideEventTriggerOptions) { for (const option of this.recording.options.overrideEventTriggerOptions) { triggerOptions.add(option); } } if (this.sensorOptions?.motion) { triggerOptions.add(1 /* EventTriggerOption.MOTION */); } // this method is overwritten by the `DoorbellController` to automatically configure EventTriggerOption.DOORBELL return triggerOptions; } /** * @private */ configureServices() { if (this.microphoneService) { this.microphoneService.getCharacteristic(Characteristic_1.Characteristic.Mute) .on("get" /* CharacteristicEventTypes.GET */, (callback) => { callback(undefined, this.microphoneMuted); }) .on("set" /* CharacteristicEventTypes.SET */, (value, callback) => { this.microphoneMuted = value; callback(); this.emitMicrophoneChange(); }); this.microphoneService.getCharacteristic(Characteristic_1.Characteristic.Volume) .on("get" /* CharacteristicEventTypes.GET */, (callback) => { callback(undefined, this.microphoneVolume); }) .on("set" /* CharacteristicEventTypes.SET */, (value, callback) => { this.microphoneVolume = value; callback(); this.emitMicrophoneChange(); }); } if (this.speakerService) { this.speakerService.getCharacteristic(Characteristic_1.Characteristic.Mute) .on("get" /* CharacteristicEventTypes.GET */, (callback) => { callback(undefined, this.speakerMuted); }) .on("set" /* CharacteristicEventTypes.SET */, (value, callback) => { this.speakerMuted = value; callback(); this.emitSpeakerChange(); }); this.speakerService.getCharacteristic(Characteristic_1.Characteristic.Volume) .on("get" /* CharacteristicEventTypes.GET */, (callback) => { callback(undefined, this.speakerVolume); }) .on("set" /* CharacteristicEventTypes.SET */, (value, callback) => { this.speakerVolume = value; callback(); this.emitSpeakerChange(); }); } // make the sensor services available to the RecordingManagement. if (this.motionService) { this.recordingManagement?.sensorServices.push(this.motionService); } if (this.occupancyService) { this.recordingManagement?.sensorServices.push(this.occupancyService); } } rtpStreamManagementDisabledThroughOperatingMode() { return this.recordingManagement ? !this.recordingManagement.operatingModeService.getCharacteristic(Characteristic_1.Characteristic.HomeKitCameraActive).value : false; } /** * @private */ handleControllerRemoved() { this.handleFactoryReset(); for (const management of this.streamManagements) { management.destroy(); } this.streamManagements.splice(0, this.streamManagements.length); this.microphoneService = undefined; this.speakerService = undefined; this.recordingManagement?.destroy(); this.recordingManagement = undefined; this.removeAllListeners(); } /** * @private */ handleFactoryReset() { this.streamManagements.forEach(management => management.handleFactoryReset()); this.recordingManagement?.handleFactoryReset(); this.microphoneMuted = false; this.microphoneVolume = 100; this.speakerMuted = false; this.speakerVolume = 100; } /** * @private */ serialize() { const streamManagementStates = []; for (const management of this.streamManagements) { const serializedState = management.serialize(); if (serializedState) { streamManagementStates.push(serializedState); } } return { streamManagements: streamManagementStates, recordingManagement: this.recordingManagement?.serialize(), }; } /** * @private */ deserialize(serialized) { for (const streamManagementState of serialized.streamManagements) { const streamManagement = this.streamManagements[streamManagementState.id]; if (streamManagement) { streamManagement.deserialize(streamManagementState); } } if (serialized.recordingManagement) { if (this.recordingManagement) { this.recordingManagement.deserialize(serialized.recordingManagement); } else { // Active characteristic cannot be controlled if removing HSV, ensure they are all active! for (const streamManagement of this.streamManagements) { streamManagement.service.updateCharacteristic(Characteristic_1.Characteristic.Active, true); } this.stateChangeDelegate?.(); } } } /** * @private */ setupStateChangeDelegate(delegate) { this.stateChangeDelegate = delegate; for (const streamManagement of this.streamManagements) { streamManagement.setupStateChangeDelegate(delegate); } this.recordingManagement?.setupStateChangeDelegate(delegate); } /** * @private */ handleSnapshotRequest(height, width, accessoryName, reason) { // first step is to verify that the reason is applicable to our current policy const streamingDisabled = this.streamManagements .map(management => !management.getService().getCharacteristic(Characteristic_1.Characteristic.Active).value) .reduce((previousValue, currentValue) => previousValue && currentValue); if (streamingDisabled) { debug("[%s] Rejecting snapshot as streaming is disabled.", accessoryName); return Promise.reject(-70412 /* HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE */); } if (this.recordingManagement) { const operatingModeService = this.recordingManagement.operatingModeService; if (!operatingModeService.getCharacteristic(Characteristic_1.Characteristic.HomeKitCameraActive).value) { debug("[%s] Rejecting snapshot as HomeKit camera is disabled.", accessoryName); return Promise.reject(-70412 /* HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE */); } const eventSnapshotsActive = operatingModeService .getCharacteristic(Characteristic_1.Characteristic.EventSnapshotsActive) .value; if (!eventSnapshotsActive) { if (reason == null) { debug("[%s] Rejecting snapshot as reason is required due to disabled event snapshots.", accessoryName); return Promise.reject(-70401 /* HAPStatus.INSUFFICIENT_PRIVILEGES */); } else if (reason === 1 /* ResourceRequestReason.EVENT */) { debug("[%s] Rejecting snapshot as even snapshots are disabled.", accessoryName); return Promise.reject(-70412 /* HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE */); } } const periodicSnapshotsActive = operatingModeService .getCharacteristic(Characteristic_1.Characteristic.PeriodicSnapshotsActive) .value; if (!periodicSnapshotsActive) { if (reason == null) { debug("[%s] Rejecting snapshot as reason is required due to disabled periodic snapshots.", accessoryName); return Promise.reject(-70401 /* HAPStatus.INSUFFICIENT_PRIVILEGES */); } else if (reason === 0 /* ResourceRequestReason.PERIODIC */) { debug("[%s] Rejecting snapshot as periodic snapshots are disabled.", accessoryName); return Promise.reject(-70412 /* HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE */); } } } // now do the actual snapshot request. return new Promise((resolve, reject) => { // TODO test and make timeouts configurable! let timeout = setTimeout(() => { console.warn(`[${accessoryName}] The image snapshot handler for the given accessory is slow to respond! See https://homebridge.io/w/JtMGR for more info.`); timeout = setTimeout(() => { timeout = undefined; console.warn(`[${accessoryName}] The image snapshot handler for the given accessory didn't respond at all! See https://homebridge.io/w/JtMGR for more info.`); reject(-70408 /* HAPStatus.OPERATION_TIMED_OUT */); }, 17000); timeout.unref(); }, 8000); timeout.unref(); try { this.delegate.handleSnapshotRequest({ height: height, width: width, reason: reason, }, (error, buffer) => { if (!timeout) { return; } else { clearTimeout(timeout); timeout = undefined; } if (error) { if (typeof error === "number") { reject(error); } else { debug("[%s] Error getting snapshot: %s", accessoryName, error.stack); reject(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return; } if (!buffer || buffer.length === 0) { console.warn(`[${accessoryName}] Snapshot request handler provided empty image buffer!`); reject(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } else { resolve(buffer); } }); } catch (error) { if (!timeout) { return; } else { clearTimeout(timeout); timeout = undefined; } console.warn(`[${accessoryName}] Unhandled error thrown inside snapshot request handler: ${error.stack}`); reject(error instanceof hapStatusError_1.HapStatusError ? error.hapStatus : -70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } }); } } exports.CameraController = CameraController; //# sourceMappingURL=CameraController.js.map