homebridge-denon-heos-audio
Version:
Control your Denon speakers via AVR Control and/or HeosCLI
293 lines • 11.4 kB
JavaScript
import { Mutex } from "async-mutex";
import * as net from "net";
export class DenonClient {
serialNumber;
params;
socket;
sendMutex;
dataEventMutex;
pendingData;
responseCallback;
debugLogCallback;
powerUpdateCallback;
muteUpdateCallback;
volumeUpdateCallback;
constructor(serialNumber, params, debugLogCallback, powerUpdateCallback, muteUpdateCallback, volumeUpdateCallback) {
this.serialNumber = serialNumber;
this.params = params;
this.socket = undefined;
this.sendMutex = new Mutex();
this.dataEventMutex = new Mutex();
this.pendingData = "";
this.responseCallback = undefined;
this.debugLogCallback = debugLogCallback;
this.powerUpdateCallback = powerUpdateCallback;
this.muteUpdateCallback = muteUpdateCallback;
this.volumeUpdateCallback = volumeUpdateCallback;
}
async connect() {
const release = await this.sendMutex.acquire();
try {
if (!this.isConnected()) {
await this.connectUnchecked();
}
return;
}
finally {
release();
}
}
async connectUnchecked() {
// Connect
let newSocket = undefined;
try {
this.debugLog("Establishing new connection...");
this.pendingData = "";
this.responseCallback = undefined;
newSocket = await new Promise((resolve, reject) => {
newSocket = net.createConnection({
host: this.params.host,
port: this.params.port,
timeout: 0, // no timeout
}, () => {
this.debugLog("New connection established.");
resolve(newSocket);
});
// Listen for connection closure events
newSocket.on("timeout", () => {
// TODO - right now we do not have client-side timeouts
this.socket = undefined;
});
newSocket.on("close", () => {
this.debugLog("Connection has closed.");
newSocket?.destroy();
this.socket = undefined;
});
newSocket.on("error", (error) => {
this.debugLog("Received an error from the server.", error);
newSocket?.end();
setTimeout(() => {
// give the server 2 seconds to gracefully close the connection, then force the issue
if (!newSocket?.destroyed) {
this.debugLog("Connection destroyed.");
newSocket?.destroy();
}
}, 2000);
this.socket = undefined;
reject(error);
});
// Listen for responses
newSocket.on("data", (data) => {
this.dataHandler(data);
});
setTimeout(() => {
reject(new ConnectionTimeoutException(this.params));
}, this.params.connect_timeout);
});
this.socket = newSocket;
await this.subscribeToChangeEvents();
}
catch (error) {
if (newSocket) {
this.debugLog("Destroying socket.");
newSocket.destroy(); // Destroy the connection attempt
}
this.debugLog("Catching and throwing:", error);
throw error;
}
}
async sendCommand(command, commandMode, { pid, value, passPayload = false }) {
const specCommand = command[CommandMode[commandMode]];
let commandStr = specCommand.COMMAND;
if (specCommand.PARAMS) {
commandStr += specCommand.PARAMS;
}
if (pid) {
commandStr = commandStr.replace("[PID]", pid);
}
if (value) {
commandStr = commandStr.replace("[VALUE]", value);
}
if (specCommand.EXP_RES) {
const response = await this.send(commandStr, specCommand.COMMAND, specCommand.EXP_RES, passPayload);
if (command.VALUES) {
const mappedValue = findMapByValue(command.VALUES, response);
if (mappedValue === undefined) {
throw new InvalidResponseException(`Unexpected response for command ${commandStr}`, Object.values(command.VALUES).map((value) => value.VALUE), response);
}
}
return response;
}
else {
return await this.send(commandStr, specCommand.COMMAND, undefined, passPayload);
}
}
async send(command, rawCommand, expectedResponse, passPayload = false) {
const release = await this.sendMutex.acquire();
try {
if (!this.isConnected()) {
await this.connectUnchecked();
}
return await this.sendUnchecked(command, rawCommand, expectedResponse, passPayload);
}
finally {
release();
}
}
async sendUnchecked(command, rawCommand, expectedResponse, passPayload = false) {
const fullCommand = this.params.command_prefix ? this.params.command_prefix + command : command;
this.debugLog("Sending command:", fullCommand);
if (expectedResponse) {
return new Promise((resolve, reject) => {
this.responseCallback = new ResponseCallback((response) => {
resolve(response);
}, rawCommand ?? command, expectedResponse, passPayload);
this.socket.write(fullCommand + this.params.command_separator);
setTimeout(() => {
reject(new ResponseTimeoutException(fullCommand, this.params.response_timeout));
}, this.params.response_timeout);
}).finally(() => {
this.responseCallback = undefined;
});
}
else {
return new Promise((resolve) => {
this.socket.write(fullCommand + this.params.command_separator);
resolve("");
});
}
}
async dataHandler(incomingData) {
const release = await this.dataEventMutex.acquire();
let chunks;
try {
const currentData = this.pendingData ? this.pendingData + incomingData.toString() : incomingData.toString();
chunks = currentData.split(this.params.response_separator);
this.pendingData = chunks[chunks.length - 1];
}
finally {
release();
}
for (let i = 0; i <= chunks.length - 2; i++) {
this.responseRouter(chunks[i]);
}
}
debugLog(message, ...parameters) {
if (this.debugLogCallback) {
this.debugLogCallback(message, ...parameters);
}
}
isConnected() {
return this.socket !== undefined;
}
}
export var CommandMode;
(function (CommandMode) {
CommandMode[CommandMode["GET"] = 0] = "GET";
CommandMode[CommandMode["SET"] = 1] = "SET";
})(CommandMode || (CommandMode = {}));
class ResponseCallback {
callback;
command;
expectedResponse;
passPayload;
constructor(callback, command, expectedResponse, passPayload = false) {
this.callback = callback;
this.command = command;
this.expectedResponse = expectedResponse;
this.passPayload = passPayload;
}
}
export class RaceStatus {
static ID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
running = true;
raceId = RaceStatus.ID_CHARS[Math.floor(Math.random() * 36)] + RaceStatus.ID_CHARS[Math.floor(Math.random() * 36)];
isRunning() {
return this.running;
}
setRaceOver() {
this.running = false;
}
}
export var Playing;
(function (Playing) {
Playing[Playing["PLAY"] = 0] = "PLAY";
Playing[Playing["PAUSE"] = 1] = "PAUSE";
Playing[Playing["STOP"] = 2] = "STOP";
Playing[Playing["UNSUPPORTED"] = 3] = "UNSUPPORTED";
})(Playing || (Playing = {}));
export const isPlaying = {
[Playing.PLAY]: true,
[Playing.PAUSE]: false,
[Playing.STOP]: false,
[Playing.UNSUPPORTED]: false,
};
export function findMapByValue(values, mapValue) {
for (const key in values) {
if (values[key].VALUE === mapValue) {
return values[key].MAP;
}
}
return undefined;
}
export function findValueByMap(values, mapValue) {
for (const key in values) {
if (values[key].MAP === mapValue) {
return values[key].VALUE;
}
}
return undefined;
}
export class ConnectionTimeoutException extends Error {
constructor(params) {
super(`connection to ${params.host}:${params.port} timed out after ${params.connect_timeout}ms.`); // Pass the message to the parent Error class
this.name = "ConnectionTimeoutException"; // Set the error name
// Ensure the prototype chain is correctly set for instanceof checks
Object.setPrototypeOf(this, ConnectionTimeoutException.prototype);
}
}
export class ResponseTimeoutException extends Error {
constructor(command, timeout) {
super(`response for command '${command}' timed out after ${timeout}ms.`); // Pass the message to the parent Error class
this.name = "ResponseTimeoutException"; // Set the error name
// Ensure the prototype chain is correctly set for instanceof checks
Object.setPrototypeOf(this, ResponseTimeoutException.prototype);
}
}
export class CommandFailedException extends Error {
constructor(command) {
super(`Execution of command "${command}" has failed on the server side.`); // Pass the message to the parent Error class
this.name = "CommandFailedException"; // Set the error name
// Ensure the prototype chain is correctly set for instanceof checks
Object.setPrototypeOf(this, CommandFailedException.prototype);
}
}
export class InvalidResponseException extends Error {
expectedResponses;
actualResponse;
constructor(message, expectedResponses, actualResponse) {
super(InvalidResponseException.buildFullMessage(message, expectedResponses, actualResponse)); // Pass the message to the parent Error class
this.name = "InvalidResponseException"; // Set the error name
this.expectedResponses = expectedResponses;
this.actualResponse = actualResponse;
// Ensure the prototype chain is correctly set for instanceof checks
Object.setPrototypeOf(this, InvalidResponseException.prototype);
}
static buildFullMessage(message, expectedResponses, actualResponse) {
let fullMessage = message;
//let fullMessage = message;
if (expectedResponses) {
if (actualResponse) {
fullMessage += ` (Actual response: ${JSON.stringify(actualResponse)}; Expected responses: ${expectedResponses.join(" | ")})`;
}
else {
fullMessage += ` (Expected response: ${expectedResponses.join(" | ")})`;
}
}
else if (actualResponse) {
fullMessage += ` (Actual response: ${actualResponse})`;
}
return fullMessage;
}
}
//# sourceMappingURL=denonClient.js.map