homebridge-samsungtvht
Version:
Add your Samsung TV (UE40D5000 and similar models) and Home Theater (HT-D5500 and similar models) to HomeKit
1,067 lines (864 loc) • 63.9 kB
JavaScript
//'use strict';
// ****************** start of settings
// name and version
const packagejson = require('./package.json');
const PLUGIN_NAME = packagejson.name;
const PLATFORM_NAME = packagejson.platformname;
const PLUGIN_VERSION = packagejson.version;
// required node modules
// fs and path are in work, removed for release of 1.1.0
// const fs = require('fs'); // needed for persisting configured names to disc
// const path = require('path'); // needed for persisting configured names to disc
//const fsPromises = require('fs').promises; -- removed in 1.0.4, not needed
//import SamsungRemote from 'samsung-remote'; // https://github.com/natalan/samsung-remote
const SamsungRemote = require('samsung-remote'); // https://github.com/natalan/samsung-remote
// https://github.com/Samfox2/homebridge-cec-tv-platform/blob/main/index.js
//const CecController = require('cec-controller');
// exec spawns child process to run a bash script
const exec = require("child_process").exec;
// to detect dev env
var PLUGIN_ENV = ''; // controls the development environment, appended to UUID to make unique device when developing
// general constants
const NO_INPUT_ID = 999 // default to input 999, no input
const NO_INPUT_NAME = 'UNKNOWN'; // an input name that does not exist
const POWER_STATE_DEFAULT_POLLING_INTERVAL_MS = 3000; // default polling interval in millisec
const POWER_STATE_MAX_TRANSITION_TIME_S = 30; // the maximum transition time we allow for a device to come online after a power ON command, default 30 s
const mediaStateName = ["PLAY", "PAUSE", "STOP", "UNKNOWN3", "LOADING", "INTERRUPTED"];
const powerStateName = ["OFF", "ON"];
//const powerStateTransition = { NOT_TRANSITIONING: 0, TRANSITIONING_ON_TO_OFF: 1, TRANSITIONING_OFF_TO_ON: 2 }; // used by HDMI-CEC
Object.freeze(mediaStateName);
Object.freeze(powerStateName);
// global variables (urgh)
// let currentInputId;
//let currentPowerState;
//let currentMediaState;
//let targetMediaState;
let Accessory, Characteristic, Service, Categories, UUID;
// wait function
const wait=ms=>new Promise(resolve => setTimeout(resolve, ms));
// wait function with promise
function waitprom(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(ms)
}, ms )
})
}
// funtion to return a unique set of keys from an array
function uniqBy(arr, key) {
return Object.values([...arr].reverse().reduce((m, i) => {m[key.split('.').reduce((a, p) => a?.[p], i)] = i; return m;}, {}))
}
// ++++++++++++++++++++++++++++++++++++++++++++
// config
// ++++++++++++++++++++++++++++++++++++++++++++
// ++++++++++++++++++++++++++++++++++++++++++++
// platform setup
// ++++++++++++++++++++++++++++++++++++++++++++
module.exports = (api) => {
Accessory = api.platformAccessory;
Characteristic = api.hap.Characteristic;
Service = api.hap.Service;
Categories = api.hap.Categories;
UUID = api.hap.uuid;
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, samsungTvHtPlatform, true);
};
class samsungTvHtPlatform {
// build the platform. Runs once on restart
constructor(log, config, api) {
// only load if configured
if (!config) {
log.warn('WARNING: No configuration found for %s', PLUGIN_NAME);
return;
}
if (!Array.isArray(config.devices)) {
log.warn('WARNING: No devices configured for %s, please add at least one device', PLUGIN_NAME);
return;
}
// abort load if device IP address are not unique
if (uniqBy(config.devices, 'ipAddress').length != config.devices.length) {
log.warn('WARNING: IP addresses not unique for %s. Please ensure you use a unique IP address per device', PLUGIN_NAME);
return;
}
this.log = log;
this.config = config;
this.api = api;
this.devices = [];
this.debugLevel = this.config.debugLevel || 0;
// show some useful version info
this.log.info('%s v%s, node %s, homebridge v%s', packagejson.name, packagejson.version, process.version, this.api.serverVersion)
this.api.on('didFinishLaunching', () => {
if (this.debugLevel > 0) {
this.log.warn('API event: didFinishLaunching');
}
// detect if running on development environment
// customStoragePath: 'C:\\Users\\jochen\\.homebridge'
if ( this.api.user.customStoragePath.includes( 'jochen' ) ) { PLUGIN_ENV = ' DEV' }
if (PLUGIN_ENV) { this.log.warn('%s running in %s environment with debugLevel %s', PLUGIN_NAME, PLUGIN_ENV.trim(), this.debugLevel); }
if (this.config.devices.length == 0) {
this.log.warn('No devices found in config for %s', PLUGIN_NAME)
} else {
// check all devices in config
this.log.debug('Checking devices found in config for %s', PLUGIN_NAME)
for (let i = 0, len = this.config.devices.length; i < len; i++) {
//this.log("Checking device %s %s", i, this.config.devices[i]);
this.log("Loading device %s: %s", i+1, this.config.devices[i].name, this.config.devices[i].ipAddress);
// constructor(log, config, api, parent, device, deviceIndex) {
// constructor(log, config, api, platform, device, deviceIndex, pingCommand, pingResponseOn, pingResponseOff) {
let newTvHtDevice = new samsungTvHtDevice(this.log, this.config, this.api, this, i);
this.devices.push(newTvHtDevice);
}
}
// start the regular powerStateMonitor
// DISABLE FOR DEBUGGING THE CHANNEL NAME ISSUE
this.checkPowerInterval = setInterval(this.powerStateMonitor.bind(this), this.config.pingInterval * 1000 || POWER_STATE_DEFAULT_POLLING_INTERVAL_MS);
});
}
configureAccessory(platformAccessory) {
this.log.debug('configurePlatformAccessory');
}
removeAccessory(platformAccessory) {
this.log.debug('removePlatformAccessory');
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [platformAccessory]);
}
powerStateMonitor() {
// ping the devices regularly to check their power state
// need to add support for powerOnStartupTime
//this.suppressPowerStateMonitoringUntil // datetime
// for linux: pingCmd = 'ping -c 1 -w 10' + ' ' + this.config.devices[0].ipAddress;
// for win: pingCmd = 'ping -n 1 -w 10' + ' ' + this.config.devices[0].ipAddress;
// ping all devices in the config
for (let i = 0; i < this.config.devices.length; i++) {
this.devices[i].powerStateMonitorCounter++; // increment global counter by 1
let device = this.devices[i];
let logPrefix = 'powerStateMonitor(' + device.powerStateMonitorCounter + '): '; // set a log prefix for this instance of the powerStateMonitor to allow differentiation in the logs
// set a ping command
var pingCmd = this.config.pingCommand || 'ping -c 1 -w 20'; // default linux, 1 ping, 20ms wait
pingCmd = pingCmd.trim() + ' ' + this.config.devices[i].ipAddress;
this.log.debug(logPrefix + '%s pinging device with %s', device.name, pingCmd);
var self = this;
var deviceRealPowerState;
exec(pingCmd, function (error, stdout, stderr) {
self.log.debug(logPrefix + "%s ping response: %s", device.name, stdout);
self.log.debug(logPrefix + '%s powerLastKeyPress %s', device.name, device.powerLastKeyPress.toLocaleString());
var dateNow = new Date;
const secondsSinceLastPowerKeyPress = (dateNow - device.powerLastKeyPress) / 1000;
const yearsSinceLastPowerKeyPress = (dateNow.getFullYear() - device.powerLastKeyPress.getFullYear());
self.log.debug(logPrefix + '%s secondsSinceLastPowerKeyPress %s', device.name, secondsSinceLastPowerKeyPress);
self.log.debug(logPrefix + '%s yearsSinceLastPowerKeyPress %s', device.name, yearsSinceLastPowerKeyPress);
// get the current device power state from the ping results
// win10: Packets: Sent = 1, Received = 0, Lost = 1 (100% loss),
// linux: 1 packets transmitted, 0 received, 100% packet loss, time 0ms
// reworked to show 100% loss is OFF, anything else is ON
if (stdout.includes(self.config.pingResponseOff) && ((self.config.pingResponseOff || '') != '')) { // user-configred OFF response found which is not empty
self.log.debug(logPrefix + "%s is not responding to ping, power is currently OFF (using config detection)", device.name);
deviceRealPowerState = Characteristic.Active.INACTIVE;
} else if (stdout.includes('100% packet loss')) { // Linux: "1 packets transmitted, 0 received, 100% packet loss, time 0ms" detect "100% packet loss"
self.log.debug(logPrefix + "%s is not responding to ping, power is currently OFF (using Linux detection)", device.name);
deviceRealPowerState = Characteristic.Active.INACTIVE;
} else if (stdout.includes('(100% loss)')) { // Windows: "Packets: Sent = 1, Received = 0, Lost = 1 (100% loss)", detect "(100% loss)"
self.log.debug(logPrefix + "%s is not responding to ping, power is currently OFF (using Windows detection)", device.name);
deviceRealPowerState = Characteristic.Active.INACTIVE;
} else {
// any ping where we did not have 100% loss, is considered being ON, as some packets were returned
self.log.debug(logPrefix + "%s is responding to ping, power is currently ON", device.name);
deviceRealPowerState = Characteristic.Active.ACTIVE;
//self.log.debug("powerStateMonitor: WARNING %s cannot determine power state from ping result! stdout:", device.name, stdout);
//deviceRealPowerState = device.currentPowerState; // maintain current state
}
// evaluate the real vs target power states
self.log.debug(logPrefix + "%s evaluating power state. deviceRealPowerState %s, currentPowerState %s, targetPowerState %s", device.name, deviceRealPowerState, device.currentPowerState, device.targetPowerState);
if (!(deviceRealPowerState === device.currentPowerState) && (yearsSinceLastPowerKeyPress > 100)) {
// (yearsSinceLastPowerKeyPress > 100) means no HomeKit key presses were made, but a deviceRealPowerState has detected which is differen to the currentPowerState. Probably changed through a non-HomeKit method, eg physical remote control
self.log.debug(logPrefix + "%s power state change detected from device, setting currentPowerState to deviceRealPowerState %s", device.name, deviceRealPowerState);
device.targetPowerState = deviceRealPowerState;
device.currentPowerState = deviceRealPowerState;
} else if ((deviceRealPowerState != device.targetPowerState) && (yearsSinceLastPowerKeyPress < 100)) {
self.log.debug(logPrefix + "%s is currently transitioning from %s to %s. Transition time so far: %s seconds", device.name, deviceRealPowerState, device.targetPowerState, secondsSinceLastPowerKeyPress);
// change in power state was requested. yearsSinceLastPowerKeyPress helps us detect a Homebridge reboot
if ((secondsSinceLastPowerKeyPress < POWER_STATE_MAX_TRANSITION_TIME_S) && (yearsSinceLastPowerKeyPress < 100)) {
// device is currently undergoing a power state transition to the targetPowerState, set currentPowerState to targetPowerState
self.log.debug(logPrefix + "%s is still within the allowed transition time of %s seconds, setting currentPowerState to targetPowerState %s", device.name, POWER_STATE_MAX_TRANSITION_TIME_S, device.targetPowerState);
device.currentPowerState = device.targetPowerState;
} else {
// transition time has timed out, cancel the targetPowerState, reset current and target to deviceRealPowerState
self.log.debug(logPrefix + "%s transition time longer than %s seconds, transition has timed out, no power state change occured, resetting currentPowerState to %s", device.name, POWER_STATE_MAX_TRANSITION_TIME_S, deviceRealPowerState);
device.targetPowerState = deviceRealPowerState;
device.currentPowerState = deviceRealPowerState;
}
} else {
device.currentPowerState = deviceRealPowerState || device.currentPowerState; // handle null if a parse error occured
self.log.debug(logPrefix + "%s currentPowerState stays unchanged at %s", device.name, device.currentPowerState);
}
// update device status
self.log.debug(logPrefix + "%s calling device.updateDeviceState with device.currentPowerState %s", device.name, device.currentPowerState);
device.updateDeviceState(device.currentPowerState);
});
}
}
}
class samsungTvHtDevice {
// build the device. Runs once on restart
constructor(log, config, api, platform, deviceIndex) {
this.log = log;
this.api = api;
this.config = config; // the entire platform config
this.platform = platform; // the entire platform
this.deviceIndex = deviceIndex; // the current device's index
this.debugLevel = platform.debugLevel || 0; // debugLevel defaults to 0 (minimum)
this.deviceConfig = this.config.devices[deviceIndex]; // the config for the current device
this.powerStateMonitorCounter=0; // the power state monitor counter for this device
this.name = this.deviceConfig.name // device name from config
this.ipAddress = this.deviceConfig.ipAddress // device ip address from config
// setup arrays
this.inputServices = []; // loaded input services, used by the accessory, as shown in the Home app. Limited to 96
this.configuredInputs = []; // a list of inputs that have been renamed by the user. EXPERIMENTAL
this.lastRemoteKeyPressed = -1; // holds the last key pressed, -1 = no key
this.lastRemoteKeyPress0 = []; // holds the time value of the last remote button press for key index i
this.lastRemoteKeyPress1 = []; // holds the time value of the last-1 remote button press for key index i
this.lastRemoteKeyPress2 = []; // holds the time value of the last-2 remote button press for key index i
this.lastVolDownKeyPress = []; // holds the time value of the last button press for the volume down button
this.inputList = []; // holds the input list, do we really need it?
//setup variables
this.accessoryConfigured = false; // true when the accessory is configured
//filename and json used for the storage of device settings to persist after homebridge restarts
//in work, removed for release of 1.1.0
/*
this.persistFile = path.join(this.api.user.storagePath(), 'persist', 'AccessoryInfo.' + PLATFORM_NAME + '.' + this.deviceConfig.ipAddress.replaceAll('.', '_') + '.json');
console.log('persistFile set to ' + this.persistFile)
this.persistJson = ''
*/
// initial states. Will be updated by code
this.currentPowerState; // deliberately leave at undefined to detect a reboot and inital start = Characteristic.Active.INACTIVE;
this.targetPowerState = this.currentPowerState;
this.currentInputId = 1; // default startup at input 1 (first in list) NO_INPUT_ID;
this.currentMediaState = Characteristic.CurrentMediaState.STOP; // default stop
this.targetMediaState = this.currentMediaState;
this.powerLastKeyPress = new Date("1900-01-01T00:00:00Z"); // set a valid date but many years in the past
// prepare the accessory
this.prepareAccessory();
}
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// START of preparing accessory and services
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
//Prepare accessory (runs from samsungTvHtDevice)
prepareAccessory() {
if (this.debugLevel > 0) {
this.log.warn('%s: prepareAccessory', this.name);
}
// exit immediately if already configured (runs from session watchdog)
if (this.accessoryConfigured) { return }
//this.log("prepareAccessory", this.name, PLUGIN_NAME);
//this.log("prepareAccessory this.ipAddress", this.ipAddress);
const accessoryName = this.name;
// generate a constant uuid that will never change over the life of the accessory
const uuid = UUID.generate(this.ipAddress + PLUGIN_ENV);
if (this.debugLevel > 1) {
this.log.warn('%s: prepareAccessory: UUID %s', this.name, uuid);
}
// default category is TV, allow also RECEIVER (avr)
let accessoryCategory = Categories.TELEVISION;
switch (this.deviceConfig.type) {
case "receiver":
accessoryCategory = Categories.AUDIO_RECEIVER;
break;
default:
accessoryCategory = Categories.TELEVISION;
}
this.accessory = new Accessory(accessoryName, uuid, accessoryCategory);
this.prepareAccessoryInformationService(); // service 1 of 100
this.prepareTelevisionService(); // service 2 of 100
this.prepareTelevisionSpeakerService(); // service 3 of 100
this.prepareInputSourceServices(); // service 4....100
// set displayOrder
this.televisionService.getCharacteristic(Characteristic.DisplayOrder)
.value = Buffer.from(this.displayOrder).toString('base64');
this.api.publishExternalAccessories(PLUGIN_NAME, [this.accessory]);
this.accessoryConfigured = true;
}
//Prepare AccessoryInformation service
prepareAccessoryInformationService() {
if (this.debugLevel > 0) {
this.log.warn('%s: prepareAccessoryInformationService', this.name);
}
this.accessory.removeService(this.accessory.getService(Service.AccessoryInformation));
const informationService = new Service.AccessoryInformation();
informationService
.setCharacteristic(Characteristic.Name, this.name)
.setCharacteristic(Characteristic.Manufacturer, this.deviceConfig.manufacturer || 'Samsung')
.setCharacteristic(Characteristic.Model, this.deviceConfig.modelName || PLATFORM_NAME)
.setCharacteristic(Characteristic.SerialNumber, this.deviceConfig.serialNumber || 'unknown')
.setCharacteristic(Characteristic.FirmwareRevision, this.deviceConfig.firmwareRevision || PLUGIN_VERSION) // must be numeric. Non-numeric values are not displayed
// optional characteristics
// if ConfiguredName is not supplied, the accessory name and input source names may appear as default names (Input Source, Input Source 2, Input Source 3, etc)
.setCharacteristic(Characteristic.ConfiguredName, this.name) // required for iOS18
//this.log.warn('%s: prepareAccessoryInformationService: informationService:', this.name, informationService);
this.accessory.addService(informationService);
}
//Prepare Television service
prepareTelevisionService() {
if (this.debugLevel > 0) {
this.log.warn('%s: prepareTelevisionService', this.name);
}
//this.televisionService = new Service.Television(null, 'televisionService');
this.televisionService = new Service.Television(this.name, 'televisionService');
this.televisionService
.setCharacteristic(Characteristic.ConfiguredName, this.name)
.setCharacteristic(Characteristic.SleepDiscoveryMode, Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE)
// extra characteristics added here are accessible in Shortcuts and Automations (both personal and home)
.setCharacteristic(Characteristic.StatusFault, Characteristic.StatusFault.NO_FAULT) // NO_FAULT or GENERAL_FAULT
// power
this.televisionService.getCharacteristic(Characteristic.Active)
.onGet(this.getPower.bind(this))
.onSet(this.setPower.bind(this));
// active input
this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier)
.onGet(this.getActiveIdentifier.bind(this))
.onSet(this.setActiveIdentifier.bind(this));
// configured name - added to log the calls to get configured name
this.televisionService.getCharacteristic(Characteristic.ConfiguredName)
.onGet(this.getConfiguredName.bind(this))
.onSet(this.setConfiguredName.bind(this));
// remote control keys in the Apple TV Remote app
this.televisionService.getCharacteristic(Characteristic.RemoteKey)
.onSet(this.setRemoteKey.bind(this));
// the View TV Settings menu item
this.televisionService.getCharacteristic(Characteristic.PowerModeSelection)
.onSet(this.setPowerModeSelection.bind(this));
// display order of the channels
this.televisionService.getCharacteristic(Characteristic.DisplayOrder)
.onGet(this.getDisplayOrder.bind(this))
.onSet(this.setDisplayOrder.bind(this));
// Experimenting with removing some unwanted optional characteristics
// removeCharacteristic(accessory.service.getCharacteristic(Characteristic.ABC))
// this.televisionService
// .removeCharacteristic(this.televisionService.getCharacteristic(Characteristic.CurrentMediaState))
// .removeCharacteristic(this.televisionService.getCharacteristic(Characteristic.TargetMediaState))
//;
//this.televisionService.setCharacteristic(Characteristic.IsConfigured, configState)
/*
this.log('this.televisionService')
this.log(this.televisionService)
this.log('this.televisionService.Brightness.Props')
//this.log(this.televisionService.Brightness.Props)
this.log('this.televisionService Brightness')
this.log(this.televisionService.getCharacteristic(Characteristic.Brightness))
// Hidden ”hd” This characteristic is hidden from the user
this.televisionService.getCharacteristic(Characteristic.CurrentMediaState)
.setProps({
perms: [Characteristic.Perms.HIDDEN]
});
this.log('this.televisionService CurrentMediaState')
this.log(this.televisionService.getCharacteristic(Characteristic.CurrentMediaState))
*/
this.accessory.addService(this.televisionService);
}
//Prepare TelevisionSpeaker service
prepareTelevisionSpeakerService() {
if (this.debugLevel > 0) {
this.log.warn('%s: prepareTelevisionSpeakerService', this.name);
}
this.speakerService = new Service.TelevisionSpeaker(this.name + ' Speaker', 'speakerService');
this.speakerService
.setCharacteristic(Characteristic.Active, Characteristic.Active.ACTIVE)
.setCharacteristic(Characteristic.VolumeControlType, Characteristic.VolumeControlType.RELATIVE); // only relative is supported for the Apple Remote with Up/Down buttons
this.speakerService.getCharacteristic(Characteristic.VolumeSelector) // the volume selector, increment or decrement, allows the iOS device keys to be used to change volume
.onSet(this.setVolume.bind(this));
//this.speakerService.getCharacteristic(Characteristic.Volume) // percentage, 0-100
// .onSet(this.setVolume.bind(this));
this.speakerService.getCharacteristic(Characteristic.Mute)
.onSet(this.setMute.bind(this));
this.accessory.addService(this.speakerService);
this.televisionService.addLinkedService(this.speakerService);
}
//Prepare InputSource services
prepareInputSourceServices() {
// This is the input list, each input is a service, max 100 services less the services created so far
// on the samsung devices, the
// AVR: HDMI1, HDMI2, Analog
if (this.debugLevel > 1) {
this.log.warn('%s: prepareInputSourceServices', this.name);
}
//this.inputList.push({inputId: '0', inputName: 'Dummy'});
// add dummy entry at index 999 for the inputList
/*
var defaultEntry = {
inputId: NO_INPUT_ID,
inputName: NO_INPUT_NAME
}
this.inputList.push(defaultEntry);
*/
this.inputList = [];
//this.log(defaultEntry);
this.displayOrder = [];
// For Release 1.0, I'll only support the source by sending the SOURCE key, which just goes to next source
// so disable HDMI 2 and Analog AUX. these need HDMI CEC support.
// see https://developers.homebridge.io/#/service/InputSource
// see https://developers.homebridge.io/#/characteristic/InputSourceType
// see https://developers.homebridge.io/#/characteristic/InputDeviceType
// HomeKit gets upset when the number of inputs changes. So configure 20 always, set conf and vis states if a deviceconfig exists
//this.log.warn('%s: prepareInputSourceServices inputs',this.name, this.deviceConfig.inputs);
if (this.deviceConfig.inputs){
// i = identifier number starting at 1, so start loop at 1
// deviceconfig is zero-based, starting at 0
for (let i = 0; i < 4; i++) { // was 20
this.log.debug('%s: prepareInputSourceServices loading config index %s input %s',this.name,i,i+1,this.deviceConfig.inputs[i] || 'no config found');
// show only if the deviceConfig setting exists
var configState = Characteristic.IsConfigured.NOT_CONFIGURED;
var visState = Characteristic.CurrentVisibilityState.HIDDEN;
if (this.deviceConfig.inputs[i]) {
configState = Characteristic.IsConfigured.CONFIGURED;
visState = Characteristic.CurrentVisibilityState.SHOWN;
}
let inputService = new Service.InputSource('input' + (i+1).toString(), 'input' + (i+1).toString()); // displayName, subtype
inputService
.setCharacteristic(Characteristic.Identifier, i+1) // must be 1-based, so that input1 is also Identifier1, and so that CurrentActivIdentifier = same as Input number
.setCharacteristic(Characteristic.ConfiguredName, (this.deviceConfig.inputs[i] || {}).inputName || 'input' + (i+1).toString()) // Initial configured name is "inputN", Input text is 1-based
.setCharacteristic(Characteristic.InputSourceType, (this.deviceConfig.inputs[i] || {}).inputSourceType || Characteristic.InputSourceType.HDMI)
.setCharacteristic(Characteristic.InputDeviceType, (this.deviceConfig.inputs[i] || {}).inputDeviceType || Characteristic.InputDeviceType.TV)
.setCharacteristic(Characteristic.IsConfigured, configState)
.setCharacteristic(Characteristic.CurrentVisibilityState, visState)
.setCharacteristic(Characteristic.TargetVisibilityState, visState);
this.inputServices.push(inputService);
this.accessory.addService(inputService);
this.televisionService.addLinkedService(inputService);
// pushing into the array always creates a zero-based array, each push fills the next index
this.inputList.push({inputId: inputService.getCharacteristic(Characteristic.Identifier), inputName: inputService.getCharacteristic(Characteristic.ConfiguredName)});
// add DisplayOrder, see :
// https://github.com/homebridge/HAP-NodeJS/issues/644
// https://github.com/ebaauw/homebridge-zp/blob/master/lib/ZpService.js line 916: this.displayOrder.push(0x01, 0x04, identifier & 0xff, 0x00, 0x00, 0x00)
// store in a displayOrder[] array with same index number
// this.displayOrder.push(0x01, 0x04, i & 0xff, 0x00, 0x00, 0x00);
// type len inputId empty empty empty
//this.displayOrder.push(0x01, 0x04, i, 0x00, 0x00, 0x00);
// inputId is the inputIdentifier (not the index), starting index 0 = identifier 1
// types:
// 0x00 end of TLV item
// 0x01 identifier...new TLV item for displayOrder
// length: Number of following bytes, excluding type and len fields.
// value: A number of <len> bytes. Can be empty if length=0
// 0x01 0x01 xx is a valid TLV8 as it contains only 1 data byte.
// for displayOrder, the length should be 4 bytes. but short also works, but limited to 255 entries
// AQQAAAAAAQQBAAAAAQQCAAAAAQQDAAAAAAA= = 010400000000,010401000000,010402000000,010403000000,0000 (including closing 0000)
// this.displayOrder.push(0x01, 0x04, i & 0xff, 0x00, 0x00, 0x00)
this.displayOrder.push(0x01, 0x01, (i+1) & 0xff)
}
// close off the TLV8 by sending 0x00 0x00
this.displayOrder.push(0x00, 0x00); // close off the displayorder array with 0x00 0x00
}
this.log.debug('%s: prepareInputSourceServices loading complete, this.inputServices',this.name,this.inputServices);
}
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// END of preparing accessory and services
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// START state handler
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// send a remote control keypress to the device
async sendKey(keySequence) {
if (this.debugLevel > 0) {
this.log.warn('%s: sendKey: keySequence %s', this.name, keySequence);
}
// make a new remote
const remote = new SamsungRemote({
ip: this.deviceConfig.ipAddress
});
let keyArray = keySequence.trim().split(' ');
if (keyArray.length > 1) { this.log('%s: sendKey: processing keySequence', this.name, keySequence); }
// supported key1 key2 key3 wait() wait(100)
for (let i = 0; i < keyArray.length; i++) {
const keyName = keyArray[i].trim();
this.log.debug('%s: sendKey: processing key %s of %s: %s', this.name, i+1, keyArray.length, keyName);
// if a wait appears, use it
let waitDelay; // default
if (keyName.toLowerCase().startsWith('wait(')) {
this.log.debug('%s: sendKey: reading delay from %s', this.name, keyName);
waitDelay = keyName.toLowerCase().replace('wait(', '').replace(')','');
if (waitDelay == ''){ waitDelay = 100; } // default 100ms
this.log.debug('%s: sendKey: delay read as %s', this.name, waitDelay);
}
// else if not first key and last key was not wait, and next key is not wait, then set a default delay of 100 ms
else if (i>0 && i<keyArray.length-1 && !(keyArray[i-1] || '').toLowerCase().startsWith('wait(') && !(keyArray[i+1] || '').toLowerCase().startsWith('wait(')) {
this.log.debug('%s: sendKey: not first key and neiher previous key %s nor next key %s is wait(). Setting default wait of 100 ms', this.name, keyArray[i-1], keyArray[i+1]);
waitDelay = 100;
}
// add a wait if waitDelay is defined
if (waitDelay) {
if (this.debugLevel > 0) {this.log('%s: sendKey: wait %s ms', this.name, waitDelay)};
await waitprom(waitDelay);
this.log.debug('%s: sendKey: wait %s done', this.name, waitDelay);
}
// send the key if not a wait()
if (!keyName.toLowerCase().startsWith('wait(')) {
if (this.debugLevel > 0) {this.log('%s: sendKey: send %s', this.name, keyName)};
remote.send(keyName, (err) => {
if (err && (err || '' != "Timeout")) {
// Timeout ignore, this is normal with SamsungRemote, some keys just do get a response from the TV / AVR
this.log.warn("%s: sendKey: %s error %s", this.name, keyName, err);
} else {
this.log.debug('%s: sendKey: send %s done', this.name, keyName);
}
});
}
} // end for loop
}
// get the device UI status
// incomplete, to be completed if I can ever figure out how
getUiStatus() {
if (this.debugLevel > 1) {
this.log.warn('getUiStatus');
}
}
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// END state handler
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// START regular device update polling functions
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// update the device state (async)
async updateDeviceState(powerState, mediaState, inputId, callback) {
// doesn't get the data direct from the device, but rather: gets it from the variables
if (this.debugLevel > 2) {
this.log.warn('%s: updateDeviceState: powerState %s, mediaState %s, inputId %s', this.name, powerState, mediaState, inputId);
}
// grab the input variables
if (powerState != null) { this.currentPowerState = powerState }
if (mediaState != null) { this.currentMediaState = mediaState }
if (inputId != null) { this.currentInputId = inputId }
// debugging, helps a lot to see InputName
if (this.debugLevel > 2) {
//let currentInputName; // let is scoped to the current {} block
//this.log.warn('%s: updateDeviceState: this.inputList', this.name, this.inputList);
// get current input, ensure we always have a json value
let curInput = (this.inputList.find(Input => Input.inputId.value === this.currentInputId) || {});
// ensure we have a name value even for input 999
let curName = ((curInput.inputName || {}).value || NO_INPUT_NAME);
this.log.warn('%s: updateDeviceState: currentPowerState %s, currentMediaState %s [%s], currentInputId %s [%s]',
this.name,
this.currentPowerState,
this.currentMediaState, mediaStateName[this.currentMediaState],
this.currentInputId, curName
);
}
// change only if configured, and update only if changed
if (this.televisionService) {
// set power state if changed
const previousPowerState = this.televisionService.getCharacteristic(Characteristic.Active).value;
const currentPowerState = this.currentPowerState || Characteristic.Active.INACTIVE; // ensure never null
this.log.debug('%s: updateDeviceState: previousPowerState %s, currentPowerState %s',this.name, previousPowerState, currentPowerState);
if (previousPowerState !== currentPowerState) {
this.log('%s: Power changed from %s %s to %s %s',
this.name,
previousPowerState, powerStateName[previousPowerState],
currentPowerState, powerStateName[currentPowerState]);
this.televisionService.getCharacteristic(Characteristic.Active).updateValue(currentPowerState);
} else {
this.log.debug('%s: updateDeviceState: no change to current power, Characteristic.Active not updated',this.name);
}
// set active input if changed
var oldActiveIdentifier = this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier).value;
//var currentActiveIdentifier = this.inputList.findIndex(input => input.inputId === currentInputId);
var currentActiveIdentifier = NO_INPUT_ID; // fixed at NO_INPUT_ID to clear the Tile
if (currentActiveIdentifier == -1) { currentActiveIdentifier = NO_INPUT_ID } // if nothing found, set to NO_INPUT to clear the name from the Home app tile
if (oldActiveIdentifier !== currentActiveIdentifier) {
// get names from loaded input list. Using SOURCE button on the remote rolls around the input list
var oldName, newName;
if (oldActiveIdentifier == NO_INPUT_ID) {
oldName = 'UNKNOWN';
}
if (currentActiveIdentifier == NO_INPUT_ID) {
newName = 'UNKNOWN';
}
// cannot show current input as it is unknown, so log at debug level only, as inputs often hold remote keys
this.log.debug('%s: Input changed from %s %s to %s %s',
this.name,
oldActiveIdentifier + 1, oldName,
currentActiveIdentifier + 1, newName);
this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier).updateValue(currentActiveIdentifier);
} else {
this.log.debug('%s: updateDeviceState: no change to current input, Characteristic.ActiveIdentifier not updated',this.name);
}
// set current media state if changed
var oldMediaState = this.televisionService.getCharacteristic(Characteristic.CurrentMediaState).value;
if (oldMediaState !== this.currentMediaState) {
this.log('%s: Media state changed from %s %s to %s %s',
this.name,
oldMediaState, mediaStateName[oldMediaState],
this.currentMediaState, mediaStateName[this.currentMediaState]);
this.televisionService.getCharacteristic(Characteristic.CurrentMediaState).updateValue(this.currentMediaState);
}
}
return null;
}
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// END regular device update polling functions
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// START of accessory get/set state handlers
// HomeKit polls for status regularly at intervals from 2min to 15min
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// get power state
async getPower() {
// fired when the user clicks away from the Remote Control, regardless of which TV was selected
// fired when HomeKit wants to refresh the TV tile in HomeKit. Refresh occurs when tile is displayed.
// currentPowerState is updated by the polling mechanisn
//this.log('getPowerState current power state:', currentPowerState);
if (this.debugLevel > 1) {
this.log.warn('%s: getPower returning %s [%s]', this.name, this.currentPowerState || Characteristic.Active.INACTIVE, powerStateName[this.currentPowerState || Characteristic.Active.INACTIVE]);
}
return this.currentPowerState || Characteristic.Active.INACTIVE; // return current state: 0=off, 1=on. Default to OFF if null.
}
// set power state
async setPower(targetPowerState) {
// fired when the user clicks the power button in the TV accessory in HomeKit
// fired when the user clicks the TV tile in HomeKit
// fired when the first key is pressed after opening the Remote Control
// wantedPowerState is the wanted power state: 0=off, 1=on
if (this.debugLevel > 1) { this.log.warn('%s: setPower: targetPowerState:', this.name, targetPowerState, powerStateName[targetPowerState]); }
this.targetPowerState = targetPowerState;
// only take action if the target state is different to the current state
if (this.currentPowerState != this.targetPowerState) {
// check what we want to do
this.powerLastKeyPress = new Date();
this.log.debug("%s: setPower: reset powerLastKeyPress to %s", this.name, this.powerLastKeyPress.toLocaleString());
this.currentPowerState = this.targetPowerState; // to ensure HomeKit gets the correct state at next poll, regardless
if (this.targetPowerState == Characteristic.Active.INACTIVE){
// we want to turn OFF, then we can turn it off with a sendKey
// avr: BD_KEY_POWER, tv: KEY_POWER
if (this.deviceConfig.powerOffButton) {this.sendKey(this.deviceConfig.powerOffButton)};
} else {
// we want to turn ON, can turn on only via HDMI-CEC
this.log("%s: setPower: powerOnCommand: %s", this.name, this.deviceConfig.powerOnCommand);
var self = this;
if (this.deviceConfig.powerOnCommand){
exec(this.deviceConfig.powerOnCommand, function (error, stdout, stderr) {
if (stderr){self.log.warn("%s: setPower: powerOnCommand: %s", self.name, stderr);} // show any error if any generated
if (stdout){self.log.debug("%s: setPower: powerOnCommand: %s", self.name, stdout);} // show any stdOut in debug mode
});
}
}
} else {
// if current is already same as target
this.log.debug("%s: Current power state is already %s [%s], doing nothing", this.name, this.currentPowerState, powerStateName[this.currentPowerState]);
}
return
}
// get configured name
async getConfiguredName() {
// trial to see if this is called during bootup
if (this.debugLevel > 1) {
this.log.warn('%s: getConfiguredName called', this.name);
}
//in work, removed for release of 1.1.0
/*
//create a constant filename for the storage of device settings to persist after homebridge restarts
//const filename = path.join(this.api.user.storagePath(), 'persist', 'AccessoryInfo.' + PLATFORM_NAME + '.' + this.deviceConfig.ipAddress.replaceAll('.', '_') + '.json');
//this.log("getConfiguredName filename", filename)
this.persistJson = JSON.parse('{"DeviceConfiguredName":"John"}');
this.log.warn('%s: getConfiguredName this.persistFile', this.name, this.persistFile);
this.log.warn('%s: getConfiguredName this.persistJson', this.name, this.persistJson);
const data = JSON.stringify(this.persistJson)
this.log.warn('%s: getConfiguredName data', this.name, data);
fs.writeFile(this.persistFile, data, err => {
if (err) {
this.log.warn('%s: getConfiguredName writeFile error', this.name);
console.error("Error writing persistJson file:", err);
} else {
this.log.warn('%s: getConfiguredName writeFile ok', this.name);
// file written successfully
}
});
fs.readFile(this.persistFile, 'utf8', function (err, data) {
//console.log("Data: " + data )
console.log("Error: " + err )
if(!err) {
//Handle Success
console.log("Success" + data);
// Parse Data to JSON OR
//var jsonObj = JSON.parse(data)
//Send back as Response
//console.log( data );
} else {
//Handle Error
console.log("file read error", filename)
console.log("Error: " + err )
}
});
console.log(data);
*/
var currentConfiguredName = this.televisionService.getCharacteristic(Characteristic.ConfiguredName).value;
if (this.debugLevel > 1) {
this.log.warn("%s: getConfiguredName returning '%s'", this.name, currentConfiguredName);
}
return currentConfiguredName
}
// set configured name
async setConfiguredName(newName) {
// trial to see if this is called during bootup
if (this.debugLevel > 1) {
this.log.warn('%s: setConfiguredName: newName %s', this.name, newName);
}
//in work, removed for release of 1.1.0
/*
// perist to Homwbridge persist folder
// storage path, constant
this.log("this.api.user.storagePath()", this.api.user.storagePath())
this.log("this.deviceConfig.ipAddress", this.deviceConfig.ipAddress)
const filename = path.join(this.api.user.storagePath(), 'persist', 'AccessoryInfo.' + PLATFORM_NAME + '.json');
this.log("filename", filename)
//this.log("jsonData", jsonData)
//let accConfName =
//var jsonString = JSON.stringify(newName);
//this.log("jsonString", jsonString)
// write to file
fs.writeFile(filename, jsonString, function(err) {
if (err) {
this.log('persistConfig', err);
}
});
*/
return
}
// set mute state
async setMute(muteState) {
// sends the mute command
// works for TVs that accept a mute toggle command
// muteState = Boolean = True (muted) or false (notMuted)
if (this.debugLevel > 0) {
this.log.warn('%s: setMute: muteState:', this.name, muteState);
}
/*
if (callbackMute && typeof(callbackMute) === 'function') {
callbackMute();
}
*/
// mute state is a boolean, either true or false
// const NOT_MUTED = 0, MUTED = 1;
this.log('%s: Set mute: %s', this.name, (muteState) ? 'Muted' : 'Not muted');
// send only if a keycode exists
const keyCode = this.deviceConfig.muteButton;
if (keyCode.length > 0) {
this.sendKey(keyCode);
}
return
}
// set volume
async setVolume(volumeSelectorValue) {
// set the volume of the TV using bash scripts
// so volume must be handled over a different method
// here we send execute a bash command on the raspberry pi using the samsungctl command
// to control the authors samsung stereo at 192.168.0.152
if (this.debugLevel > 0) { this.log.warn('%s: setVolume: volumeSelectorValue:', this.name, volumeSelectorValue); }
// volumeSelectorValue: only 2 values possible: INCREMENT: 0, DECREMENT: 1,
this.log.debug('%s: setVolume: Set volume: %s', (volumeSelectorValue === Characteristic.VolumeSelector.DECREMENT) ? 'Down' : 'Up');
// triple rapid VolDown presses triggers setMute
var tripleVolDownPress = 10000; // default high value to prevent a tripleVolDown detection when no triple key pressed
if (volumeSelectorValue === Characteristic.VolumeSelector.DECREMENT) {
this.lastVolDownKeyPress[2] = this.lastVolDownKeyPress[1] || 0;
this.lastVolDownKeyPress[1] = this.lastVolDownKeyPress[0] || 0;
this.lastVolDownKeyPress[0] = Date.now();
tripleVolDownPress = this.lastVolDownKeyPress[0] - this.lastVolDownKeyPress[2];
// check time difference between current keyPress and 2 keyPresses ago
this.log.debug('%s: setVolume: Timediff between lastVolDownKeyPress[0] now and lastVolDownKeyPress[2]: %s ms', this.name, this.lastVolDownKeyPress[0] - this.lastVolDownKeyPress[2]);
}
// check for triple press of volDown, send setmute if tripleVolDownPress less than 1000ms
var keyCode;
if (tripleVolDownPress < 1000) {
this.log.debug('%s: Triple-press of volume down detected', this.name);
keyCode = this.deviceConfig.voldownButtonTriplePress;
} else if (volumeSelectorValue === Characteristic.VolumeSelector.INCREMENT) {
keyCode = this.deviceConfig.volupButton;
} else if (volumeSelectorValue === Characteristic.VolumeSelector.DECREMENT) {
keyCode = this.deviceConfig.voldownButton;
}
// send only if a keycode exists
if ((keyCode || {}).length > 0) {
this.sendKey(keyCode);
}
return
}
// get ActiveIdentifier (input)
// ActiveIdentifier is zero-based: 0=input1, 1=input2, etc
async getActiveIdentifier() {
// fired when the user clicks away from the iOS Device TV Remote Control, regardless of which TV was selected
// fired when the icon is clicked in HomeKit and HomeKit requests a refresh
// currentInputId is updated by the polling mechanisn
// must return a valid index, and must never return null
// Input 0 is the first entry in the input list, input 1 the next, and so on
// Active identifier (as retrieved in Shortcuts)
//this.log.warn('%s: getActiveIdentifier: called, this.currentInputId:', this.name, this.currentInputId);
// find the currentInputId in the inputs and return the currentActiveInput once found
// this allows HomeKit to show the selected current input
// search for input by the displayName, which is configured as "inputN" where N is the index: 1=first, 2=next, and so on
// currentActiveIdentifierZeroBased is zero-based: 0=input1, 1=input2, etc
//this.log.warn('%s: getActiveIdentifier this.inputServices', this.name, this.inputServices);
var currentActiveIdentifierZeroBased = this.inputServices.findIndex(input => input.displayName === 'input' + this.currentInputId) ; // returns -1 if not found, returns the index (0-based) when found. Index 0 = Input 1
if (currentActiveIdentifierZeroBased == -1) { currentActiveIdentifierZeroBased = NO_INPUT_ID -1 } // if nothing found (-1), set to NO_INPUT_ID to clear the name from the Home app tile
//this.log.warn('%s: getActiveIdentifier currentActiveIdentifierZeroBased', this.name, currentActiveIdentifierZeroBased);
//this.log.warn('%s: getActiveIdentifier this.inputServices[currentActiveIdentifierZeroBased]', this.name, this.inputServices[currentActiveIdentifierZeroBased]);
// get name if currentActiveInput is within bounds of the inputServices array
var currentInputName; // default empty
if ((currentActiveIdentifierZeroBased >= 0) && (currentActiveIdentifierZeroBased < this.inputServices.length)) {
currentInputName = this.inputServices[currentActiveIdentifierZeroBased].getCharacteristic(Characteristic.ConfiguredName).value;
}
// currentActiveIdentifierZeroBased is zero-based. 0=Input1, 1=Input2, etc.
if (this.debugLevel > 0) {
this.log.warn('%s: getActiveIdentifier returning currentActiveIdentifier %s = input %s [%s]', this.name, currentActiveIdentifierZeroBased + 1, currentActiveIdentifierZeroBased + 1, currentInputName || NO_INPUT_NAME);
}
return currentActiveIdentifierZeroBased + 1; // first input has ActiveIdentifier 1, 2nd has 2, and so on
}
// set ActiveIdentifier (input, uint32)
async setActiveIdentifier(newInputId) {
// newInputId is an integer, 1-based. 1 is the first entry in the input list, input 2 the next, and so on
this.log.warn('%s: setActiveIdentifier newInputId:', this.name, newInputId);
//one day I'll implement the HDMI CEC input control, then I'll need these functions:
/*
var currentInputName = 'UNKNOWN';
var foundIndex = this.inputList.findIndex(input => input.inputId === currentInputId);
if (foundIndex > -1) { currentInputName = this.inputList[foundIndex].InputName; }
this.log('Change input from %s %s to %s %s', currentInputId, currentInputName, input.inputId, input.InputName);
this.switchInput(input.inputId);
*/
// get keycode only if we have an input (sometimes not defined)
// Remember: newInputId is 1-based, but the array is zero-based, so subtract 1
var keyCode = '';
if (newInputId !== undefined) {
keyCode = this.deviceConfig.inputs[newInputId-1].inputKeyCode;
this.log.warn('%s: setActiveIdentifier newActiveIdentifier %s inputs keyCode %s', this.name, newInputId, keyCode );
}
// send only if a keycode exists
if ((keyCode || {}).length > 0) {
this.sendKey(keyCode);
var currentInputName; // default empty
// Remember: newInputId is 1-based, but the array is zero-based, so subtract 1
if ((newInputId > 0) && (newInputId <= this.inputServices.length)) {
currentInputName = this.inputServices[newInputId-1].getCharacteristic(Characteristic.ConfiguredName).value;
}
this.log.warn('%s: setActiveIdentifier setting newInputId to: %s [%s]', this.name, newInputId, currentInputName );
this.currentInputId = newInputId; // persist to homebridge, InputId is the Identifier value, which is 1-based. ActiveIdentifier is zero-based
this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier).updateValue(newInputId);
}
// disabled in v1.1.0, we now allow the persistance of the selected value
/*
// immediately reset the input back to nothing to clear any scenes and clear the tile display
if (this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier).value != NO_INPUT_ID) {
this.log.warn('%s: setActiveIdentifier setting ActiveIdentifier to NO_INPUT_ID %s :', this.name, NO_INPUT_ID);
this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier).updateValue(NO_INPUT_ID);
} else {
this.log.debug('%s: setActiveIdentifier: ActiveIdentifier OK, no need to change', this.name);
}
*/
return
}
// set input name
async setInputName(inputName) {
// fired by the user changing an input name in Home app accessory setup
if (this.debugLevel > 0) {
this.log.warn('%s: setInputName inputName:', this.name, inputName);
}
return
};
// set power mode selection (View TV Settings menu option)
async setPowerModeSelection(state) {
// fired by the View TV Settings command in the HomeKit TV accessory Settings
if (this.debugLevel > 0) {
this.log.warn('%s: setPowerModeSelection s