UNPKG

@o-lukas/homebridge-smartthings-tv

Version:

This is a plugin for Homebridge. It offers some basic functions to control Samsung TVs using the SmartThings API.

614 lines 28.3 kB
import { wake } from 'wol'; import ping from 'ping'; import { SmartThingsAccessory } from './smartThingsAccessory.js'; import { Apps } from './tvApps.js'; import axios from 'axios'; /** * Class implements a SmartThings TV accessory. */ export class TvAccessory extends SmartThingsAccessory { logCapabilities; registerApplications; validateApplications; pollingInterval; cyclicCallsLogging; macAddress; ipAddress; inputSources; applications; informationKey; service; speakerService = undefined; inputSourceServices = []; capabilities = []; activeIdentifierChangeTime = 0; activeIdentifierChangeValue = 0; constructor(name, device, component, client, log, platform, accessory, logCapabilities, registerApplications, validateApplications, pollingInterval, cyclicCallsLogging, macAddress = undefined, ipAddress = undefined, inputSources = undefined, applications = undefined, informationKey = undefined) { super(device, component, client, platform, accessory, log); this.logCapabilities = logCapabilities; this.registerApplications = registerApplications; this.validateApplications = validateApplications; this.pollingInterval = pollingInterval; this.cyclicCallsLogging = cyclicCallsLogging; this.macAddress = macAddress; this.ipAddress = ipAddress; this.inputSources = inputSources; this.applications = applications; this.informationKey = informationKey; this.accessory.getService(this.platform.Service.AccessoryInformation) .setCharacteristic(this.platform.Characteristic.Name, name); this.service = this.accessory.getService(this.platform.Service.Television) ?? this.accessory.addService(this.platform.Service.Television); this.service .setCharacteristic(this.platform.Characteristic.SleepDiscoveryMode, this.platform.Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE) .setCharacteristic(this.platform.Characteristic.ConfiguredName, name); this.service.getCharacteristic(this.platform.Characteristic.RemoteKey) .onSet(this.setRemoteKey.bind(this)); } /** * Registers all available capabilities of the SmartThings Component. */ async registerCapabilities() { this.logInfo('Registering capabilities for component %s', this.component.id); for (const reference of this.component.capabilities) { try { await this.registerCapability(await this.client.capabilities.get(reference.id, reference.version ?? 0)); } catch (error) { let errorMessage = 'unknown'; if (error instanceof Error) { errorMessage = error.message; } let statusCode = -1; if (axios.isAxiosError(error)) { statusCode = error.response?.status ?? -1; } this.logError('Registering capability \'%s\' failed: [%s] %s', reference.id, statusCode, errorMessage); } } if (this.registerApplications && this.validateApplications && this.inputSourceServices.length > 0) { this.logInfo('Resetting active identifier to %s because application registration needed to open all applications', this.inputSourceServices[0].getCharacteristic(this.platform.Characteristic.ConfiguredName).value); try { await this.setActiveIdentifier(0); } catch (error) { this.logWarn('Active identifier could not be reset - probably because device is in invalid state'); } } } /** * Returns all available picture modes for the current device. * * @returns the available picture modes or undefined */ async getPictureModes() { const status = await this.getCapabilityStatus('custom.picturemode', true); if (!status) { return undefined; } const map = [...new Set(status?.supportedPictureModesMap.value)]; return { capability: 'custom.picturemode', command: 'setPictureMode', prefix: 'Picture', values: map.map(s => { return { id: s.id, name: s.name, value: s.name }; }), }; } /** * Returns all available sound modes for the current device. * * @returns the available sound modes or undefined */ async getSoundModes() { const status = await this.getCapabilityStatus('custom.soundmode', true); if (!status) { return undefined; } const map = [...new Set(status?.supportedSoundModesMap.value)]; return { capability: 'custom.soundmode', command: 'setSoundMode', prefix: 'Sound', values: map.map(s => { return { id: s.id, name: s.name, value: s.name }; }), }; } /** * Returns all available ambient modes for the current device. * * @returns the available ambient modes or undefined */ async getAmbientModes() { const status = await this.getCapabilityStatus('samsungvd.ambientContent', true); if (!status) { return undefined; } const array = [...new Set(status?.supportedAmbientApps.value)]; return { capability: 'samsungvd.ambientContent', command: 'setAmbientContent', prefix: 'Ambient', values: array.map(s => { return { id: s, name: s, value: s }; }), }; } /** * Returns all available input sources for the current device. * * @returns the available input sources */ async getInputSources() { const sources = this.inputSourceServices.map(s => { return { id: s.getCharacteristic(this.platform.Characteristic.Identifier).value, name: s.getCharacteristic(this.platform.Characteristic.ConfiguredName).value, value: s.name, }; }); return { capability: 'samsungvd.mediaInputSource', command: 'setInputSource', prefix: 'Input source', values: [...new Set(sources)], }; } /** * Returns whether the speaker service is available. * * @returns TRUE in case speaker service is available - FALSE otherwise */ hasSpeakerService() { return this.speakerService !== undefined; } /** * Registers the SmartThings Capablity if it's functionality is implemented. * * @param capability the Capability */ async registerCapability(capability) { let inputSourcePollingStarted = false; if (this.logCapabilities) { this.logDebug('Available capability: %s', JSON.stringify(capability, null, 2)); } if (capability.id && !this.capabilities.includes(capability.id)) { this.capabilities.push(capability.id); } switch (capability.id) { case 'switch': this.logCapabilityRegistration(capability); this.service.getCharacteristic(this.platform.Characteristic.Active) .onSet(this.setActive.bind(this)) .onGet(this.getActive.bind(this)); this.startStatusPolling(capability.name, this.service, this.platform.Characteristic.Active, this.getActive.bind(this, this.cyclicCallsLogging), this.pollingInterval); break; case 'audioVolume': if (!this.speakerService) { this.speakerService = this.accessory.getService(this.platform.Service.TelevisionSpeaker) ?? this.accessory.addService(this.platform.Service.TelevisionSpeaker); this.service.addLinkedService(this.speakerService); } this.logCapabilityRegistration(capability); this.speakerService .setCharacteristic(this.platform.Characteristic.Active, this.platform.Characteristic.Active.ACTIVE); this.speakerService .setCharacteristic(this.platform.Characteristic.VolumeControlType, this.platform.Characteristic.VolumeControlType.ABSOLUTE); this.speakerService .getCharacteristic(this.platform.Characteristic.Volume) .onGet(this.getVolume.bind(this)) .onSet(this.setVolume.bind(this)); this.speakerService.getCharacteristic(this.platform.Characteristic.VolumeSelector) .onSet(this.setVolumeSelector.bind(this)); this.startStatusPolling(capability.name, this.speakerService, this.platform.Characteristic.Volume, this.getVolume.bind(this, this.cyclicCallsLogging), this.pollingInterval); break; case 'audioMute': if (!this.speakerService) { this.speakerService = this.accessory.getService(this.platform.Service.TelevisionSpeaker) ?? this.accessory.addService(this.platform.Service.TelevisionSpeaker); this.service.addLinkedService(this.speakerService); } this.logCapabilityRegistration(capability); this.speakerService.getCharacteristic(this.platform.Characteristic.Mute) .onSet(this.setMute.bind(this)) .onGet(this.getMute.bind(this)); this.startStatusPolling(capability.name, this.speakerService, this.platform.Characteristic.Mute, this.getMute.bind(this, this.cyclicCallsLogging), this.pollingInterval); break; case 'samsungvd.mediaInputSource': this.logCapabilityRegistration(capability); await this.registerAvailableMediaInputSources(); if (this.inputSourceServices.length > 0) { this.service.getCharacteristic(this.platform.Characteristic.ActiveIdentifier) .onSet(this.setActiveIdentifier.bind(this)) .onGet(this.getActiveIdentifier.bind(this)); if (!inputSourcePollingStarted) { inputSourcePollingStarted = true; this.startStatusPolling('activeIdentifier', this.service, this.platform.Characteristic.ActiveIdentifier, this.getActiveIdentifier.bind(this, this.cyclicCallsLogging), this.pollingInterval); } } break; case 'custom.launchapp': if (this.registerApplications) { this.logCapabilityRegistration(capability); let appsToRegister = this.applications ?? Apps; if (this.validateApplications) { appsToRegister = await this.getAvailableLaunchApplications(); const config = this.platform.getPlatformConfig(); const deviceMapping = config.deviceMappings.find((d) => d.deviceId === this.device.deviceId); this.logInfo('Updating list of applications in configuration with valid values: %s', JSON.stringify(appsToRegister, null, 4)); deviceMapping.applications = appsToRegister; this.logInfo('De-activating validation of application list in configuration'); deviceMapping.validateApplications = false; this.platform.savePlatformConfig(config); } await this.registerLaunchApplications(appsToRegister); if (this.inputSourceServices.length > 0) { this.service.getCharacteristic(this.platform.Characteristic.ActiveIdentifier) .onSet(this.setActiveIdentifier.bind(this)) .onGet(this.getActiveIdentifier.bind(this)); if (!inputSourcePollingStarted) { inputSourcePollingStarted = true; this.startStatusPolling('activeIdentifier', this.service, this.platform.Characteristic.ActiveIdentifier, this.getActiveIdentifier.bind(this, this.cyclicCallsLogging), this.pollingInterval); } } } else { this.logInfo('Not registering capability because registering of applications has been disabled: %s', capability.name); } break; case 'samsungvd.remoteControl': this.logInfo('Possible infoKey values are: %s', JSON.stringify(capability.commands?.send.arguments?.find(a => a.name === 'keyValue')?.schema.enum, null, 2)); break; case 'custom.picturemode': case 'custom.soundmode': case 'samsungvd.ambient': case 'samsungvd.ambientContent': this.logCapabilityState(capability); break; } } /** * Setter for Homebridge accessory Active property. * * @param value the CharacteristicValue */ async setActive(value) { this.logDebug('Set active to: %s', value); if (value) { if (this.macAddress) { this.logDebug('Use wake-on-lan functionality because mac-address has been configured'); if (await wake(this.macAddress)) { this.logDebug('Successfully woke device'); } else { this.logError('Could not wake device - if this error keeps occuring try to disable wake-on-lan functionality'); } } else { await this.executeCommand('switch', 'on'); } } else { await this.executeCommand('switch', 'off'); } } /** * Getter for Homebridge accessory Active property. * * @param log flag to turn logging on/off * @returns the CharacteristicValue */ async getActive(log = true) { if (this.ipAddress) { try { const status = await ping.promise.probe(this.ipAddress); if (log) { this.logDebug('ping status: %s', status); } return status?.alive; } catch (exc) { this.logError('error when pinging device: %s\n\ ping command fails mostly because of permission issues - falling back to SmartThings API for getting active state', exc); return false; } } const status = await this.getCapabilityStatus('switch', log); return status?.switch.value === 'on' ? true : false; } /** * Setter for Homebridge accessory VolumeSelector property. * * @param value the CharacteristicValue */ async setVolumeSelector(value) { const increment = value === this.platform.Characteristic.VolumeSelector.INCREMENT; this.logDebug('%s volume', increment ? 'Increasing' : 'Decreasing'); await this.executeCommand('audioVolume', increment ? 'volumeUp' : 'volumeDown'); } /** * Setter for Homebridge accessory Volume property. * * @param value the CharacteristicValue */ async setVolume(value) { this.logDebug('Set volume to: %s', value); await this.executeCommand('audioVolume', 'setVolume', [value ?? 0]); } /** * Getter for Homebridge accessory Volume property. * * @param log flag to turn logging on/off * @returns the CharacteristicValue */ async getVolume(log = true) { const status = await this.getCapabilityStatus('audioVolume', log); return status?.volume.value ?? 0; } /** * Setter for Homebridge accessory Mute property. * * @param value the CharacteristicValue */ async setMute(value) { this.logDebug('Set mute to: %s', value); await this.executeCommand('audioMute', value ? 'mute' : 'unmute'); } /** * Getter for Homebridge accessory Mute property. * * @param log flag to turn logging on/off * @returns the CharacteristicValue */ async getMute(log = true) { const status = await this.getCapabilityStatus('audioMute', log); return status?.mute.value === 'muted' ? true : false; } /** * Setter for Homebridge accessory ActiveIdentifier property. * * @param value the CharacteristicValue */ async setActiveIdentifier(value) { this.logDebug('Set active identifier to: %s', value); const inputSource = this.inputSourceServices[value]; const inputSourceType = inputSource.getCharacteristic(this.platform.Characteristic.InputSourceType).value; this.activeIdentifierChangeTime = Date.now(); this.activeIdentifierChangeValue = value; if (inputSourceType === this.platform.Characteristic.InputSourceType.APPLICATION) { await this.executeCommand('custom.launchapp', 'launchApp', [inputSource.name ?? '']); } else { await this.executeCommand('samsungvd.mediaInputSource', 'setInputSource', [inputSource.name ?? '']); } } /** * Getter for Homebridge accessory ActiveIdentifier property. * * @param log flag to turn logging on/off * @returns the CharacteristicValue */ async getActiveIdentifier(log = true) { const status = await this.getCapabilityStatus('samsungvd.mediaInputSource', log); if (Date.parse(status?.inputSource.timestamp ?? '') > this.activeIdentifierChangeTime) { const id = this.inputSourceServices.findIndex(inputSource => inputSource.name === status?.inputSource.value); if (log) { this.logDebug('ActiveIdentifier has been changed on the device - using API result: %s', id); } if (id < 0) { this.logWarn('Could not find input source for name \'%s\' - using first input source \'%s\' as active identifier', status?.inputSource.value, this.inputSourceServices[0].name); return 0; } return id; } else { if (log) { this.logDebug('ActiveIdentifier has not been changed on the device - using temporary result: %s', this.activeIdentifierChangeValue); } return this.activeIdentifierChangeValue; } } /** * Setter for Homebridge accessory RemoteKey property. * * @param value the CharacteristicValue */ async setRemoteKey(value) { switch (value) { case this.platform.Characteristic.RemoteKey.REWIND: if (this.validateRemoteKeyCapability('mediaPlayback', 'REWIND')) { await this.executeCommand('mediaPlayback', 'rewind'); } break; case this.platform.Characteristic.RemoteKey.FAST_FORWARD: if (this.validateRemoteKeyCapability('mediaPlayback', 'FAST_FORWARD')) { await this.executeCommand('mediaPlayback', 'fastForward'); } break; case this.platform.Characteristic.RemoteKey.NEXT_TRACK: if (this.validateRemoteKeyCapability('mediaTrackControl', 'NEXT_TRACK')) { await this.executeCommand('mediaTrackControl', 'nextTrack'); } break; case this.platform.Characteristic.RemoteKey.PREVIOUS_TRACK: if (this.validateRemoteKeyCapability('mediaTrackControl', 'PREVIOUS_TRACK')) { await this.executeCommand('mediaTrackControl', 'previousTrack'); } break; case this.platform.Characteristic.RemoteKey.ARROW_UP: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'ARROW_UP')) { await this.executeCommand('samsungvd.remoteControl', 'send', ['UP']); } break; case this.platform.Characteristic.RemoteKey.ARROW_DOWN: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'ARROW_DOWN')) { await this.executeCommand('samsungvd.remoteControl', 'send', ['DOWN']); } break; case this.platform.Characteristic.RemoteKey.ARROW_LEFT: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'ARROW_LEFT')) { await this.executeCommand('samsungvd.remoteControl', 'send', ['LEFT']); } break; case this.platform.Characteristic.RemoteKey.ARROW_RIGHT: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'ARROW_RIGHT')) { await this.executeCommand('samsungvd.remoteControl', 'send', ['RIGHT']); } break; case this.platform.Characteristic.RemoteKey.SELECT: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'SELECT')) { await this.executeCommand('samsungvd.remoteControl', 'send', ['OK']); } break; case this.platform.Characteristic.RemoteKey.BACK: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'BACK')) { await this.executeCommand('samsungvd.remoteControl', 'send', ['BACK']); } break; case this.platform.Characteristic.RemoteKey.EXIT: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'EXIT')) { await this.executeCommand('samsungvd.remoteControl', 'send', ['HOME']); } break; case this.platform.Characteristic.RemoteKey.PLAY_PAUSE: if (this.validateRemoteKeyCapability('mediaPlayback', 'PLAY_PAUSE')) { await this.executeCommand('mediaPlayback', 'play'); } break; case this.platform.Characteristic.RemoteKey.INFORMATION: if (this.validateRemoteKeyCapability('samsungvd.remoteControl', 'INFORMATION')) { await this.executeCommand('samsungvd.remoteControl', 'send', [this.informationKey ?? 'MENU']); } break; } } /** * Validates that the SmartThings Capability with id passed in is available * * @param capabilityId the identifier of the SmartThings Capablity * @returns TRUE in case capability is available - FALSE otherwise */ validateCapability(capabilityId) { return this.capabilities.includes(capabilityId); } /** * Validates that the SmartThings Capability needed to execute the remote key is available. * * @param capabilityId the identifier of the SmartThings Capablity * @param remoteKey the remote key * @returns TRUE in case capability is available - FALSE otherwise */ validateRemoteKeyCapability(capabilityId, remoteKey) { if (this.validateCapability(capabilityId)) { return true; } else { this.logError('can\'t handle RemoteKey %s because %s capability is not available', remoteKey, capabilityId); return false; } } /** * Registers all available media input sources (e.g. HDMI inputs). */ async registerAvailableMediaInputSources() { const status = await this.client.devices.getCapabilityStatus(this.device.deviceId, this.component.id, 'samsungvd.mediaInputSource'); const supportedInputSources = [...new Set(status.supportedInputSourcesMap.value)]; if (this.inputSources) { this.logInfo('Overriding default input sources map "%s" with custom map "%s"', JSON.stringify(supportedInputSources, null, 2), JSON.stringify(this.inputSources, null, 2)); } for (const inputSource of this.inputSources ?? supportedInputSources) { this.registerInputSource(inputSource.id, inputSource.name); } } /** * Registers all applications passed in. */ async registerLaunchApplications(apps) { for (const app of apps) { for (const appId of app.ids) { let name = app.name; if (app.ids.length > 1) { name += ' (' + appId + ')'; } this.registerInputSource(appId, name, this.platform.Characteristic.InputSourceType.APPLICATION); } } } /** * Returns all installed applications. * * Tests a list of known application ids by trying to open them. If opening succeeded the application will be added * to returned list. If it fails the application will not be added. If multiple ids for an application are available * the first successfully tested id will be used. */ async getAvailableLaunchApplications() { if (!await this.getActive(false)) { this.logWarn('Registering applications will probably not work because TV is not turned on'); } const applications = []; for (const app of this.applications ?? Apps) { this.logDebug('Try to launch application %s with ids: %s', app.name, app.ids.join(', ')); for (const appId of app.ids) { try { await this.client.devices.executeCommand(this.device.deviceId, { capability: 'custom.launchapp', command: 'launchApp', arguments: [appId], }); applications.push({ ids: [appId], name: app.name }); break; } catch (exc) { continue; } } } return applications; } /** * Registers a Homebridge input source. * * @param id the input source id * @param name the input source display name * @param inputSource the InputSourceType or @code undefined @endcode to use @link guessInputSourceType @endlink * to determine InputSourceType */ registerInputSource(id, name, inputSource = undefined) { this.logInfo('Registering input source: %s (%s)', name, id); let inputSourceType = inputSource; if (inputSourceType === undefined) { inputSourceType = this.guessInputSourceType(id); this.logDebug('Guessed input source type for %s is: %i', name, inputSourceType); } const inputSourceService = this.accessory.getService(id) ?? this.accessory.addService(this.platform.Service.InputSource, id, id); inputSourceService.name = id; inputSourceService .setCharacteristic(this.platform.Characteristic.Identifier, this.inputSourceServices.length) .setCharacteristic(this.platform.Characteristic.ConfiguredName, name) .setCharacteristic(this.platform.Characteristic.IsConfigured, this.platform.Characteristic.IsConfigured.CONFIGURED) .setCharacteristic(this.platform.Characteristic.InputSourceType, inputSourceType); this.service.addLinkedService(inputSourceService); this.inputSourceServices.push(inputSourceService); } /** * Guesses the InputSourceType from the identifier of the input source. * * @param inputSourceId the identifier of the input source * @returns the InputSourceType (HDMI|TUNER|OTHER) */ guessInputSourceType(inputSourceId) { if (inputSourceId.startsWith('HDMI')) { return this.platform.Characteristic.InputSourceType.HDMI; } else if (inputSourceId === 'dtv') { return this.platform.Characteristic.InputSourceType.TUNER; } else { return this.platform.Characteristic.InputSourceType.OTHER; } } } //# sourceMappingURL=tvAccessory.js.map