homebridge-samsungtvht
Version:
Add your Samsung TV (UE40D5000 and similar models) and Home Theater (HT-D5500 and similar models) to HomeKit
1,260 lines (1,147 loc) • 67.2 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
const SamsungRemote = require("samsung-remote"); // https://github.com/natalan/samsung-remote
// used for the remote. Define only once for improved performance
const KEY_LAYER_SUFFIXES = { 0: "", 1: "DoubleTap", 2: "TripleTap" };
// used for the ping power poll
const CURRENT_YEAR = new Date().getFullYear();
// exec spawns child process to run a bash script
const exec = require("child_process").exec;
// to detect dev env
let 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"];
Object.freeze(mediaStateName);
Object.freeze(powerStateName);
// global variables (urgh)
let Accessory, Characteristic, Service, Categories, UUID;
let REMOTE_KEY_MAP = null; // initialize lazily, gets populated the first time the remote is used
// ++++++++++++++++++++++++++++++++++++++++++++
// useful functions
// ++++++++++++++++++++++++++++++++++++++++++++
// wait function
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// function 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;
}, {}),
);
}
// function to populate the REMOTE_KEY_MAP the first time the remote is used
function getRemoteKeyMap() {
if (REMOTE_KEY_MAP) return REMOTE_KEY_MAP; // already built, reuse it
// remote control key map — one entry per button
// RemoteKey: the Apple Remote Control key name, key values are 0-11,15 as of iOS 14-26. Keys 2 & 3 no longer feature in the Apple Remote Control GUI
// default: the default Samsung remote key name that will be used when no homebridge config is found for the button
// configKey: the homebridge config key name, to which can be appended DoubleTap or TripleTap
REMOTE_KEY_MAP = {
[Characteristic.RemoteKey.REWIND]: {
// key 0
default: "KEY_REWIND",
configKey: "rewindButton",
},
[Characteristic.RemoteKey.FAST_FORWARD]: {
// key 1
default: "KEY_FF",
configKey: "fastForwardButton",
},
[Characteristic.RemoteKey.NEXT_TRACK]: {
// key 2 legacy Apple button, not selectable on iOS 14 and above, might return in the future
default: "KEY_LEFT",
configKey: "nextTrackButton",
},
[Characteristic.RemoteKey.PREVIOUS_TRACK]: {
// key 3 legacy Apple button, not selectable on iOS 14 and above, might return in the future
default: "KEY_RIGHT",
configKey: "previousTrackButton",
},
[Characteristic.RemoteKey.ARROW_UP]: {
// key 4
default: "KEY_UP",
configKey: "arrowUpButton",
},
[Characteristic.RemoteKey.ARROW_DOWN]: {
// key 5
default: "KEY_DOWN",
configKey: "arrowDownButton",
},
[Characteristic.RemoteKey.ARROW_LEFT]: {
// key 6
default: "KEY_LEFT",
configKey: "arrowLeftButton",
},
[Characteristic.RemoteKey.ARROW_RIGHT]: {
// key 7
default: "KEY_RIGHT",
configKey: "arrowRightButton",
},
[Characteristic.RemoteKey.SELECT]: {
// key 8
default: "KEY_ENTER",
configKey: "selectButton",
},
[Characteristic.RemoteKey.BACK]: {
// key 9
default: "KEY_RETURN",
configKey: "backButton",
},
[Characteristic.RemoteKey.EXIT]: {
// key 10
default: "KEY_EXIT",
configKey: "exitButton",
},
[Characteristic.RemoteKey.PLAY_PAUSE]: {
// key 11
default: "KEY_PLAY",
configKey: "playPauseButton",
},
[Characteristic.RemoteKey.INFORMATION]: {
// key 15
default: "KEY_MENU",
configKey: "infoButton",
},
};
return REMOTE_KEY_MAP;
}
// ++++++++++++++++++++++++++++++++++++++++++++
// 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
// Run with: NODE_ENV=development homebridge
if (process.env.NODE_ENV === "development") {
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(
"Loading device %s: %s (%s)",
i + 1,
this.config.devices[i].name,
this.config.devices[i].ipAddress,
);
const newTvHtDevice = new SamsungTvHtDevice(
this.log,
this.config,
this.api,
this,
i,
);
this.devices.push(newTvHtDevice);
}
}
// start the regular powerStateMonitor
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
// ping each device separately
for (const device of this.devices) {
device.powerStateMonitorCounter++; // increment global counter by 1
// log the ping command
if (this.debugLevel > 2) {
this.log.warn(
`${device.logPrefix} %s pinging device with %s`,
device.name,
device.pingCmd,
);
}
// terminate loop iteration if a ping is still in flight
if (device.pingInFlight) {
if (this.debugLevel > 2) {
this.log.debug(
`${device.logPrefix} %s ping already in flight, skipping tick`,
device.name,
);
}
continue; // terminate execution of the statements in the current iteration of the current loop
}
let deviceRealPowerState;
// stderr not used, Destructuring or naming unused params with _ is a good convention to signal intent
device.pingInFlight = true;
exec(device.pingCmd, (_error, stdout, _stderr) => {
device.pingInFlight = false; // always clear, even on error
const logPrefix = device.logPrefix; // getter fires once here
const nowMs = Date.now();
const secondsSinceLastPowerKeyPress =
(nowMs - device.powerLastKeyPress.getTime()) / 1000;
// powerLastKeyPress is initialised to year 1900 to signal "never pressed".
// Comparing years detects this sentinel. Keep the Date object only for this.
const yearsSinceLastPowerKeyPress =
CURRENT_YEAR - device.powerLastKeyPress.getFullYear();
if (this.debugLevel > 2) {
this.log.warn(
`${logPrefix} %s ping response: %s`,
device.name,
stdout,
);
this.log.warn(
`${logPrefix} %s powerLastKeyPress: %s`,
device.name,
device.powerLastKeyPress.toLocaleString(),
);
this.log.warn(
`${logPrefix} %s secondsSinceLastPowerKeyPress: %s`,
device.name,
secondsSinceLastPowerKeyPress,
);
this.log.warn(
`${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 (
device.pingResponseOff !== "" &&
stdout.includes(device.pingResponseOff)
) {
// user-configured OFF response found which is not empty
if (this.debugLevel > 2) {
this.log.warn(
`${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"
if (this.debugLevel > 2) {
this.log.warn(
`${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)"
if (this.debugLevel > 2) {
this.log.warn(
`${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
if (this.debugLevel > 2) {
this.log.warn(
`${logPrefix} %s is responding to ping, power is currently ON`,
device.name,
);
}
deviceRealPowerState = Characteristic.Active.ACTIVE;
}
// evaluate the real vs target power states
if (this.debugLevel > 2) {
this.log.warn(
`${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 different to the currentPowerState. Probably changed through a non-HomeKit method, eg physical remote control
if (this.debugLevel > 2) {
this.log.warn(
`${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
) {
if (this.debugLevel > 2) {
this.log.warn(
`${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
) {
// device is currently undergoing a power state transition to the targetPowerState, set currentPowerState to targetPowerState
if (this.debugLevel > 2) {
this.log.warn(
`${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
if (this.debugLevel > 2) {
this.log.warn(
`${logPrefix} %s transition time longer than %s seconds, transition has timed out, no power state change occurred, 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 occurred
if (this.debugLevel > 2) {
this.log.warn(
`${logPrefix} %s currentPowerState stays unchanged at %s`,
device.name,
device.currentPowerState,
);
}
}
// update device status
if (this.debugLevel > 2) {
this.log.warn(
`${logPrefix} %s calling device.updateDeviceState with currentPowerState %s`,
device.name,
device.currentPowerState,
);
}
device.updateDeviceState(device.currentPowerState).catch((err) => {
this.log.warn(
"%s: updateDeviceState error during poll: %s",
device.name,
err.message ?? err,
);
});
});
}
}
}
class SamsungTvHtDevice {
// build the device. Runs once on restart
constructor(log, config, api, platform, deviceIndex) {
this.log = log;
this.api = api;
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 = 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
if (this.debugLevel > 0) {
this.log.warn("%s: now in SamsungTvHtDevice", this.name);
}
// pingCmd cached once; OS-specific default applied here, reused each poll tick
// for linux: pingCmd = 'ping -c 1 -w 20' + ' ' + ipAddress
// for win: pingCmd = 'ping -n 1 -w 20' + ' ' + ipAddress
const baseCmd = (config.pingCommand || "ping -c 1 -w 20").trim();
this.pingCmd = `${baseCmd} ${this.ipAddress}`;
this.pingResponseOff = (config.pingResponseOff || "").trim();
this.pingInFlight = false; // true while an exec ping is running for this device
// remote control key press times
this.doublePressTime = config.doublePressTime || 250;
// this.triplePressTime = config.triplePressTime || 450; // future use
this.doublePressDelayTime = config.doublePressDelayTime || 300;
// create the remote once — reused on every remote keypress
this.remote = new SamsungRemote({ ip: this.deviceConfig.ipAddress });
// setup arrays
this.inputServices = []; // loaded input services, used by the accessory, as shown in the Home app. Limited to 96
// TODO: this.configuredInputs = []; // a list of inputs that have been renamed by the user. EXPERIMENTAL TODO
this.lastRemoteKeyPressed = -1; // holds the last key pressed, -1 = no key
// Memory optimisation: using a plain object {} avoids any array overhead for sparse numeric keys
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
//setup variables
this.accessoryConfigured = false; // true when the accessory is configured
// initial states. Will be updated by code
this.currentPowerState = undefined; // deliberately leave at undefined to detect a reboot and initial start = Characteristic.Active.INACTIVE;
this.targetPowerState = this.currentPowerState;
this.lastKnownPowerState = undefined; // tracks previous power state to detect ON transitions
// defaultInputId: if set, the tile shows this input when the device powers on.
// If not set, defaults to NO_INPUT_ID (blank tile) on each power-on.
this.defaultInputId = this.deviceConfig.defaultInput || NO_INPUT_ID;
this.currentInputId = this.defaultInputId; // default startup at defaultInputId
this.currentMediaState = Characteristic.CurrentMediaState.PLAY; // default play
this.targetMediaState = this.currentMediaState;
// tracks last value pushed to HAP
this.lastPushedPowerState = undefined;
this.lastPushedActiveIdentifier = undefined;
this.lastPushedMediaState = undefined;
this.powerLastKeyPress = new Date("1900-01-01T00:00:00Z"); // set a valid date but many years in the past
// prepare the accessory
this.prepareAccessory();
}
// logPrefix getter — built on demand, not on every poll tick
get logPrefix() {
return `powerStateMonitor(${this.powerStateMonitorCounter}): `;
}
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
// 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;
}
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)
const accessoryCategory =
this.deviceConfig.type === "receiver"
? Categories.AUDIO_RECEIVER
: 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.displayOrderBase64 = Buffer.from(this.displayOrder).toString("base64");
this.televisionService.getCharacteristic(
Characteristic.DisplayOrder,
).value = this.displayOrderBase64; // set HAP once at startup
this.api.publishExternalAccessories(PLUGIN_NAME, [this.accessory]);
this.accessoryConfigured = true;
// Push ConfiguredName after publishing so Eve receives the HAP notify event
// Eve subscribes to event notifications rather than polling GET,
// so updateValue() is required to populate the name in Eve after the accessory registers.
this.televisionService
.getCharacteristic(Characteristic.ConfiguredName)
.updateValue(this.name);
}
//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.accessory.addService(informationService);
}
//Prepare Television service
prepareTelevisionService() {
if (this.debugLevel > 0) {
this.log.warn("%s: prepareTelevisionService", this.name);
}
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));
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.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.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
if (this.deviceConfig.inputs) {
// i = array index starting at 0; Identifier is set to i+1 (1-based) to match ActiveIdentifier
// deviceConfig is zero-based, starting at 0
// fixed size at 20 inputs, the configState and visState controls what is seen by the user
for (let i = 0; i < 20; i++) {
this.log.debug(
"%s: prepareInputSourceServices loading config index %s input %s: %s",
this.name,
i,
i + 1,
this.deviceConfig.inputs[i] || "no config found",
);
// show only if the deviceConfig setting exists using a ternary: condition ? execute when true : execute when false
const hasInput = Boolean(this.deviceConfig.inputs[i]);
const inputLabel = `input${i+1}`;
const configState = hasInput
? Characteristic.IsConfigured.CONFIGURED
: Characteristic.IsConfigured.NOT_CONFIGURED;
const visState = hasInput
? Characteristic.CurrentVisibilityState.SHOWN
: Characteristic.CurrentVisibilityState.HIDDEN;
const inputService = new Service.InputSource(inputLabel, inputLabel); // displayName, subtype
// Capture the name into a variable — used both in setCharacteristic and updateValue below
const inputName = (this.deviceConfig.inputs[i] || {}).inputName || inputLabel;
// set Characteristics on the inputService
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,inputName,
)
.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);
// Push ConfiguredName via notify so Eve receives it after the accessory registers.
// setCharacteristic only sets the cached HAP value; Eve subscribes to event notifications
// and won't see the name unless updateValue() fires the notify event explicitly.
inputService
.getCharacteristic(Characteristic.ConfiguredName)
.updateValue(inputName);
// 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
}
// build a lookup map: inputId (1-based) → zero-based array index
// avoids O(n) findIndex/find scans on every HomeKit poll
// Map.get() is a hash lookup — it takes the same time whether there are 2 inputs or 200.
// The old findIndex and find both walk the array from the start until they find a match, doing
// a characteristic read or string comparison on each element.
// With 20 inputs the difference is small in absolute terms, but getActiveIdentifier is called by
// HomeKit repeatedly, so eliminating the scan entirely is the right approach.
// The Map itself costs a tiny amount of memory — 20 entries of two integers each — and is built exactly once at startup.
// always assigned here (outside if block) so .get() is safe without ?. at every call site
this.inputIdIndexMap = new Map(
this.inputServices.map((svc, idx) => [
svc.getCharacteristic(Characteristic.Identifier).value,
idx,
]),
);
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);
}
const keyArray = keySequence.trim().split(/\s+/); // split on any whitespace
const keyArrayLower = keyArray.map((k) => k.toLowerCase());
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];
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
const keyLower = keyArrayLower[i]; // already lowercased
if (keyLower.startsWith("wait(")) {
this.log.debug(
"%s: sendKey: reading delay from %s",
this.name,
keyName,
);
// use double replace for legibility
waitDelay = keyLower.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 &&
!keyArrayLower[i - 1]?.startsWith("wait(") &&
!keyArrayLower[i + 1]?.startsWith("wait(")
) {
this.log.debug(
"%s: sendKey: not first key and neither 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 wait(waitDelay);
this.log.debug("%s: sendKey: wait %s done", this.name, waitDelay);
}
// send the key if not a wait()
if (!keyLower.startsWith("wait(")) {
if (this.debugLevel > 0) {
this.log("%s: sendKey: send %s", this.name, keyName);
}
this.remote.send(keyName, (err) => {
if (err && err !== "Timeout") {
// Timeout ignore, this is normal with SamsungRemote, some keys just don't 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);
}
});
}
}
}
// 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) {
// 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 provided
if (powerState !== undefined) {
this.currentPowerState = powerState;
}
if (mediaState !== undefined) {
this.currentMediaState = mediaState;
}
if (inputId !== undefined) {
this.currentInputId = inputId;
}
// debugging, helps a lot to see InputName
if (this.debugLevel > 2) {
// get current service (input), ensure we always have a json value
// O(1) map lookup instead of O(n) find scan
const curIdx = this.inputIdIndexMap.get(this.currentInputId);
const curService =
curIdx !== undefined ? this.inputServices[curIdx] : undefined;
// ensure we have a name value even for input 999
const curName = curService
? curService.getCharacteristic(Characteristic.ConfiguredName).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 currentPowerState =
this.currentPowerState ?? Characteristic.Active.INACTIVE; // ensure never null
this.log.debug(
"%s: updateDeviceState: lastPushedPowerState %s, currentPowerState %s",
this.name,
this.lastPushedPowerState,
currentPowerState,
);
if (this.lastPushedPowerState !== currentPowerState) {
const lastPushedPowerStateName =
this.lastPushedPowerState !== undefined
? powerStateName[this.lastPushedPowerState]
: "unknown (startup)";
this.log(
"%s: Power changed from %s %s to %s %s",
this.name,
this.lastPushedPowerState ?? "—",
lastPushedPowerStateName,
currentPowerState,
powerStateName[currentPowerState],
);
this.televisionService
.getCharacteristic(Characteristic.Active)
.updateValue(currentPowerState);
this.lastPushedPowerState = currentPowerState;
} else {
this.log.debug(
"%s: updateDeviceState: no change to current power, Characteristic.Active not updated",
this.name,
);
}
// set active input based on power state transitions
const isPowerOn = currentPowerState === Characteristic.Active.ACTIVE;
const wasPowerOn =
this.lastKnownPowerState === Characteristic.Active.ACTIVE;
const powerJustTurnedOn = isPowerOn && !wasPowerOn;
// Reset to NO_INPUT_ID when the device just powered on, because we cannot
// know what input the device is actually on at this point.
if (powerJustTurnedOn) {
this.currentInputId = this.defaultInputId;
this.log.debug(
"%s: updateDeviceState: device just turned ON, resetting ActiveIdentifier to %s",
this.name,
this.defaultInputId,
);
}
// Update lastKnownPowerState now that we have handled the transition
this.lastKnownPowerState = currentPowerState;
const oldActiveIdentifier = this.lastPushedActiveIdentifier;
// When TV is OFF, always show NO_INPUT_ID (blank tile).
// When TV is ON, show whatever currentInputId is — either NO_INPUT_ID (just
// powered on, no input selected yet) or the last input the user picked via HomeKit.
const currentActiveIdentifier = isPowerOn
? this.currentInputId
: NO_INPUT_ID;
if (oldActiveIdentifier !== currentActiveIdentifier) {
const oldName =
oldActiveIdentifier === NO_INPUT_ID ? "UNKNOWN" : oldActiveIdentifier;
const newName =
currentActiveIdentifier === NO_INPUT_ID
? "UNKNOWN"
: currentActiveIdentifier;
if (this.debugLevel > 0) {
this.log.warn(
"%s: Input changed from %s to %s",
this.name,
oldName,
newName,
);
}
this.televisionService
.getCharacteristic(Characteristic.ActiveIdentifier)
.updateValue(currentActiveIdentifier);
this.lastPushedActiveIdentifier = currentActiveIdentifier;
} else {
if (this.debugLevel > 2) {
this.log.warn(
"%s: updateDeviceState: no change to current input, Characteristic.ActiveIdentifier not updated",
this.name,
);
}
}
// set current media state if changed
if (this.lastPushedMediaState !== this.currentMediaState) {
const lastPushedMediaStateName =
this.lastPushedMediaState !== undefined
? mediaStateName[this.lastPushedMediaState]
: "unknown (startup)";
this.log(
"%s: Media state changed from %s %s to %s %s",
this.name,
this.lastPushedMediaState ?? "—",
lastPushedMediaStateName,
this.currentMediaState,
mediaStateName[this.currentMediaState],
);
this.televisionService
.getCharacteristic(Characteristic.CurrentMediaState)
.updateValue(this.currentMediaState);
this.lastPushedMediaState = 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 mechanism
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
this.lastPushedPowerState = this.targetPowerState;
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,
);
if (this.deviceConfig.powerOnCommand) {
exec(this.deviceConfig.powerOnCommand, (_error, stdout, stderr) => {
if (stderr) {
this.log.warn(
"%s: setPower: powerOnCommand: %s",
this.name,
stderr,
);
} // show any error if any generated
if (stdout) {
this.log.debug(
"%s: setPower: powerOnCommand: %s",
this.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],
);
}
}
// 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);
}
// TODO: allow user to change the name in HomeKit
// const currentConfiguredName = this.televisionService.getCharacteristic(
// Characteristic.ConfiguredName,
//).value;
if (this.debugLevel > 1) {
this.log.warn(
"%s: getConfiguredName returning '%s'",
this.name,
this.name, // set once at startup, never changes — no HAP read needed,
);
}
return this.name; // set once at startup, never changes — no HAP read needed;
}
// 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);
}
}
// 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);
}
// 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);
}
}
// set volume
async setVolume(volumeSelectorValue) {
// sends the volume command
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",
this.name,
volumeSelectorValue === Characteristic.VolumeSelector.DECREMENT
? "Down"
: "Up",
);
// triple rapid VolDown presses triggers setMute
let 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[