homebridge-denon-heos-audio
Version:
Control your Denon speakers via AVR Control and/or HeosCLI
514 lines • 22.6 kB
JavaScript
import { CommandFailedException, CommandMode, DefaultInput, DenonClient, findMapByValue, findValueByMap, InvalidResponseException, isPlaying, Playing, } from "./denonClient.js";
import * as DenonProtocol from "./denonProtocol.js";
export class DenonClientHeosCli extends DenonClient {
controlMode = DenonProtocol.ControlMode.HEOSCLI;
static PROTOCOL = {
GLOBAL_CHANGE_EVENT_REGEX: /^event\/(\w+)$/,
PID_REGEX: /pid=(\d+)/,
EVENT_SUB: {
SET: {
COMMAND: "system/register_for_change_events",
PARAMS: "?enable=[VALUE]",
EXP_RES: /enable=(\w+)/,
},
VALUES: {
ON: {
VALUE: "on",
MAP: true,
},
OFF: {
VALUE: "off",
MAP: false,
},
},
},
PLAYERS: {
GET: {
COMMAND: "player/get_players",
PARAMS: "",
EXP_RES: /^$/,
},
},
PLAY_STATE: {
GET: {
COMMAND: "player/get_play_state",
PARAMS: "?pid=[PID]",
EXP_RES: /state=(\w+)/,
},
SET: {
COMMAND: "player/set_play_state",
PARAMS: "?pid=[PID]&state=[VALUE]",
EXP_RES: /state=(\w+)/,
},
EVENT: {
COMMAND: "event/player_state_changed",
EXP_RES: /state=(\w+)/,
},
VALUES: {
PLAY: {
VALUE: "play",
MAP: Playing.PLAY,
},
PAUSE: {
VALUE: "pause",
MAP: Playing.PAUSE,
},
STOP: {
VALUE: "stop",
MAP: Playing.STOP,
},
},
},
PLAY_NEXT: {
SET: {
COMMAND: "player/play_next",
PARAMS: "?pid=[PID]",
EXP_RES: /.*/,
},
},
PLAY_PREVIOUS: {
SET: {
COMMAND: "player/play_previous",
PARAMS: "?pid=[PID]",
EXP_RES: /.*/,
},
},
MUTE: {
GET: {
COMMAND: "player/get_mute",
PARAMS: "?pid=[PID]",
EXP_RES: /state=(\w+)/,
},
SET: {
COMMAND: "player/set_mute",
PARAMS: "?pid=[PID]&state=[VALUE]",
EXP_RES: /state=(\w+)/,
},
EVENT: {
COMMAND: "event/player_volume_changed",
EXP_RES: /mute=(\w+)/,
},
VALUES: {
ON: {
VALUE: "on",
MAP: true,
},
OFF: {
VALUE: "off",
MAP: false,
},
},
},
VOLUME: {
GET: {
COMMAND: "player/get_volume",
PARAMS: "?pid=[PID]",
EXP_RES: /level=(\d+)/,
},
SET: {
COMMAND: "player/set_volume",
PARAMS: "?pid=[PID]&level=[VALUE]",
EXP_RES: /level=(\d+)/,
},
EVENT: {
COMMAND: "event/player_volume_changed",
EXP_RES: /level=(\d+)/,
},
},
VOLUME_UP: {
SET: {
COMMAND: "player/volume_up",
PARAMS: "?pid=[PID]&step=[VALUE]",
EXP_RES: /step=(\d+)/,
},
},
VOLUME_DOWN: {
SET: {
COMMAND: "player/volume_down",
PARAMS: "?pid=[PID]&step=[VALUE]",
EXP_RES: /step=(\d+)/,
},
},
INPUT: {
GET: {
COMMAND: "player/get_now_playing_media",
PARAMS: "?pid=[PID]",
EXP_RES: /.*/,
},
SET: {
COMMAND: "browse/play_input",
PARAMS: "?pid=[PID]&input=inputs/[VALUE]",
EXP_RES: /input=inputs\/(\w+)/,
},
EVENT: {
COMMAND: "event/player_now_playing_changed",
EXP_RES: /.*/,
}, // TODO implement change event
},
};
static HEOS_SOURCES = {
1: new DefaultInput("pandora", "Pandora"),
2: new DefaultInput("rhapsody", "Rhapsody"),
3: new DefaultInput("tunein", "TuneIn"),
4: new DefaultInput("spotify", "Spotify"),
5: new DefaultInput("deezer", "Deezer"),
6: new DefaultInput("napster", "Napster"),
7: new DefaultInput("iheartradio", "iHeartRadio"),
8: new DefaultInput("siriusxm", "SiriusXM"),
9: new DefaultInput("soundcloud", "SoundCloud"),
10: new DefaultInput("tidal", "Tidal"),
12: new DefaultInput("rdio", "Rdio"),
13: new DefaultInput("amazonmusic", "Amazon Music"),
15: new DefaultInput("moodmix", "Mood Mix"),
16: new DefaultInput("juke", "Juke"),
18: new DefaultInput("qqmusic", "QQ Music"),
30: new DefaultInput("qobuz", "qobuz"),
1024: new DefaultInput("local", "Local Network"),
1025: new DefaultInput("heos_playlists", "HEOS Playlists"),
1026: new DefaultInput("heos_history", "HEOS History"),
1027: new DefaultInput("heos_auxinputs", "AUX Inputs"),
1028: new DefaultInput("heos_favorites", "HEOS Favorites"),
};
static INPUT_SOURCES = [
new DefaultInput("aux_in_1", "AUX In 1"),
new DefaultInput("aux_in_2", "AUX In 2"),
new DefaultInput("aux_in_3", "AUX In 3"),
new DefaultInput("aux_in_4", "AUX In 4"),
new DefaultInput("aux_single", "AUX Single"),
new DefaultInput("aux1", "AUX 1"),
new DefaultInput("aux2", "AUX 2"),
new DefaultInput("aux3", "AUX 3"),
new DefaultInput("aux4", "AUX 4"),
new DefaultInput("aux5", "AUX 5"),
new DefaultInput("aux6", "AUX 6"),
new DefaultInput("aux7", "AUX 7"),
new DefaultInput("aux_8k", "AUX 8K"),
new DefaultInput("line_in_1", "Line In 1"),
new DefaultInput("line_in_2", "Line In 2"),
new DefaultInput("line_in_3", "Line In 3"),
new DefaultInput("line_in_4", "Line In 4"),
new DefaultInput("coax_in_1", "Coax In 1"),
new DefaultInput("coax_in_2", "Coax In 2"),
new DefaultInput("optical_in_1", "Optical In 1"),
new DefaultInput("optical_in_2", "Optical In 2"),
new DefaultInput("optical_in_3", "Optical In 3"),
new DefaultInput("hdmi_in_1", "HDMI In 1"),
new DefaultInput("hdmi_in_2", "HDMI In 2"),
new DefaultInput("hdmi_in_3", "HDMI In 3"),
new DefaultInput("hdmi_in_4", "HDMI In 4"),
new DefaultInput("hdmi_arc_1", "HDMI ARC 1"),
new DefaultInput("cable_sat", "Cable/SAT"),
new DefaultInput("dvd", "DVD"),
new DefaultInput("bluray", "Blu-ray"),
new DefaultInput("game", "Game"),
new DefaultInput("game2", "Game 2"),
new DefaultInput("mediaplayer", "Media Player"),
new DefaultInput("cd", "CD"),
new DefaultInput("tuner", "Tuner"),
new DefaultInput("hdradio", "HD Radio"),
new DefaultInput("tvaudio", "TV Audio"),
new DefaultInput("phono", "Phono"),
new DefaultInput("usbdac", "USB DAC"),
new DefaultInput("analog_in_1", "Analog In 1"),
new DefaultInput("analog_in_2", "Analog In 2"),
new DefaultInput("recorder_in_1", "Recorder In 1"),
new DefaultInput("tv", "TV"),
];
static DEFAULT_INPUT_SOURCES = [
...Object.values(DenonClientHeosCli.HEOS_SOURCES),
...DenonClientHeosCli.INPUT_SOURCES,
new DefaultInput("airplay", "AirPlay"),
];
player_id;
constructor(serialNumber, host, connect_timeout, response_timeout, debugLogCallback, powerUpdateCallback, muteUpdateCallback, volumeUpdateCallback, inputUpdateCallback) {
super(serialNumber, {
host: host,
port: DenonProtocol.ControlMode.HEOSCLI,
connect_timeout: connect_timeout,
response_timeout: response_timeout,
command_prefix: "heos://",
command_separator: "\r\n",
response_separator: "\r\n",
all_responses_to_generic: false,
}, DenonClientHeosCli.DEFAULT_INPUT_SOURCES, debugLogCallback, powerUpdateCallback, muteUpdateCallback, volumeUpdateCallback, inputUpdateCallback);
this.player_id = undefined;
this.findPlayerId();
}
async findPlayerId() {
const payload_str = await super.sendCommand(DenonClientHeosCli.PROTOCOL.PLAYERS, CommandMode.GET, { passPayload: true });
const payload = JSON.parse(payload_str);
if (!Array.isArray(payload)) {
throw new InvalidResponseException("Payload is not an array!", undefined, payload_str);
}
for (const player of payload) {
if (player.serial === this.serialNumber) {
this.player_id = player.pid;
return;
}
}
throw new InvalidResponseException("Player list does not include serial " + this.serialNumber);
}
async subscribeToChangeEvents() {
const commandStr = (DenonClientHeosCli.PROTOCOL.EVENT_SUB.SET.COMMAND + DenonClientHeosCli.PROTOCOL.EVENT_SUB.SET.PARAMS).replace("[VALUE]", DenonClientHeosCli.PROTOCOL.EVENT_SUB.VALUES.ON.VALUE);
const response = await this.sendUnchecked(commandStr, DenonClientHeosCli.PROTOCOL.EVENT_SUB.SET.COMMAND, DenonClientHeosCli.PROTOCOL.EVENT_SUB.SET.EXP_RES);
const mappedValue = findMapByValue(DenonClientHeosCli.PROTOCOL.EVENT_SUB.VALUES, response);
if (mappedValue === undefined) {
throw new InvalidResponseException("No valid response!", Object.values(DenonClientHeosCli.PROTOCOL.EVENT_SUB.VALUES).map((value) => value.VALUE), response);
}
if (response !== DenonClientHeosCli.PROTOCOL.EVENT_SUB.VALUES.ON.VALUE) {
throw new CommandFailedException(commandStr);
}
}
async sendCommand(command, commandMode, { value, passPayload = false }) {
if (!this.player_id) {
await this.findPlayerId();
}
return super.sendCommand(command, commandMode, { pid: this.player_id, value: value, passPayload: passPayload });
}
responseRouter(response) {
let r_obj;
try {
r_obj = JSON.parse(response);
}
catch {
throw new InvalidResponseException("Received a response that is not valid JSON!", undefined, response);
}
if (r_obj.heos === undefined || r_obj.heos.command === undefined) {
throw new InvalidResponseException("Received response that does not follow HeosCLI specifications", undefined, response);
}
if (this.responseCallback && this.responseCallback.command === r_obj.heos.command.trim()) {
if (r_obj.heos.success !== undefined && r_obj.heos.success !== "success") {
const error = new CommandFailedException(this.responseCallback.command);
this.responseCallback = undefined;
throw error;
}
if (r_obj.heos.message !== undefined) {
let out = undefined;
const pid_match = r_obj.heos.message.match(DenonClientHeosCli.PROTOCOL.PID_REGEX);
if (!pid_match || Number(pid_match[1]) === this.player_id) {
const message_match = r_obj.heos.message.match(this.responseCallback.expectedResponse);
if (message_match) {
out = message_match.length > 1 ? message_match[1] : r_obj.heos.message;
}
}
if (out !== undefined) {
if (this.responseCallback.passPayload) {
if (r_obj.payload === undefined) {
throw new InvalidResponseException("Received a response does not include a payload!");
}
out = JSON.stringify(r_obj.payload);
}
this.debugLog("Received response:", response);
this.responseCallback.callback(out);
this.responseCallback = undefined;
if (this.params.all_responses_to_generic) {
this.genericResponseHandler(r_obj);
}
}
else {
this.genericResponseHandler(r_obj);
}
}
else {
this.genericResponseHandler(r_obj);
}
}
else {
this.genericResponseHandler(r_obj);
}
}
genericResponseHandler(r_obj) {
if (!r_obj.heos.command.match(DenonClientHeosCli.PROTOCOL.GLOBAL_CHANGE_EVENT_REGEX)) {
return; // not an event
}
const pid_match = r_obj.heos.message.match(DenonClientHeosCli.PROTOCOL.PID_REGEX);
if (!pid_match || Number(pid_match[1]) !== this.player_id) {
return; // not this player
}
this.debugLog("Received change event:", JSON.stringify(r_obj));
// Play state
if (r_obj.heos.command === DenonClientHeosCli.PROTOCOL.PLAY_STATE.EVENT.COMMAND) {
const match = r_obj.heos.message.match(DenonClientHeosCli.PROTOCOL.PLAY_STATE.EVENT.EXP_RES);
if (!match) {
throw new InvalidResponseException("Unexpected play state", Object.values(DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES).map((value) => value.VALUE), r_obj.heos.message);
}
const mappedValue = findMapByValue(DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES, match[1]);
if (mappedValue === undefined) {
throw new InvalidResponseException("Unexpected play state", Object.values(DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES).map((value) => value.VALUE), r_obj.heos.message);
}
if (this.powerUpdateCallback) {
this.powerUpdateCallback(isPlaying[mappedValue]);
}
}
// Mute
if (r_obj.heos.command === DenonClientHeosCli.PROTOCOL.MUTE.EVENT.COMMAND) {
const match = r_obj.heos.message.match(DenonClientHeosCli.PROTOCOL.MUTE.EVENT.EXP_RES);
if (!match) {
throw new InvalidResponseException("Unexpected mute state", Object.values(DenonClientHeosCli.PROTOCOL.MUTE.VALUES).map((value) => value.VALUE), r_obj.heos.message);
}
const mappedValue = findMapByValue(DenonClientHeosCli.PROTOCOL.MUTE.VALUES, match[1]);
if (mappedValue === undefined) {
throw new InvalidResponseException("Unexpected mute state", Object.values(DenonClientHeosCli.PROTOCOL.MUTE.VALUES).map((value) => value.VALUE), r_obj.heos.message);
}
if (this.muteUpdateCallback) {
this.muteUpdateCallback(mappedValue);
}
}
// Volume
if (r_obj.heos.command === DenonClientHeosCli.PROTOCOL.VOLUME.EVENT.COMMAND) {
const match = r_obj.heos.message.match(DenonClientHeosCli.PROTOCOL.VOLUME.EVENT.EXP_RES);
if (!match) {
throw new InvalidResponseException("Unexpected volume level", [DenonClientHeosCli.PROTOCOL.VOLUME.EVENT.EXP_RES.toString()], r_obj.heos.message);
}
if (this.volumeUpdateCallback) {
this.volumeUpdateCallback(Number(match[1]));
}
}
// Input
if (r_obj.heos.command === DenonClientHeosCli.PROTOCOL.INPUT.EVENT.COMMAND) {
// 'now playing changed' only gives us an indication that something has changed, but no information on what has changed.
// Therefore, we need to actively query the current input.
if (this.inputUpdateCallback) {
this.sendCommand(DenonClientHeosCli.PROTOCOL.INPUT, CommandMode.GET, { passPayload: true }).then((payload_str) => {
const inputID = DenonClientHeosCli.inputPayloadToInputID(payload_str);
this.inputUpdateCallback(inputID);
});
}
}
}
async getPlayingInternal() {
const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.PLAY_STATE, CommandMode.GET, {});
const mappedValue = findMapByValue(DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES, response);
return mappedValue;
}
async getPlaying(raceStatus) {
const playing = await this.getPlayingInternal();
if (raceStatus && !raceStatus.isRunning()) {
this.debugLog(`getPlaying was late to the party [race id: ${raceStatus.raceId}].`);
}
return playing;
}
async setPlaying(playing) {
const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.PLAY_STATE, CommandMode.SET, {
value: findValueByMap(DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES, playing),
});
const mappedValue = findMapByValue(DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES, response);
return mappedValue;
}
async setPlayNext() {
await this.sendCommand(DenonClientHeosCli.PROTOCOL.PLAY_NEXT, CommandMode.SET, {});
}
async setPlayPrevious() {
await this.sendCommand(DenonClientHeosCli.PROTOCOL.PLAY_PREVIOUS, CommandMode.SET, {});
}
async getPower(raceStatus) {
const playing = await this.getPlayingInternal();
if (raceStatus && !raceStatus.isRunning() && this.powerUpdateCallback) {
this.powerUpdateCallback(isPlaying[playing]);
this.debugLog(`getPower was late to the party [race id: ${raceStatus.raceId}].`);
}
return isPlaying[playing];
}
async setPower(power) {
const newPlaying = await this.setPlaying(power ? Playing.PLAY : Playing.STOP);
if (this.powerUpdateCallback) {
this.powerUpdateCallback(isPlaying[newPlaying]);
}
return isPlaying[newPlaying];
}
async getMute(raceStatus) {
const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.MUTE, CommandMode.GET, {});
const mappedValue = findMapByValue(DenonClientHeosCli.PROTOCOL.MUTE.VALUES, response);
if (raceStatus && !raceStatus.isRunning() && this.muteUpdateCallback) {
this.muteUpdateCallback(mappedValue);
this.debugLog(`getMute was late to the party [race id: ${raceStatus.raceId}].`);
}
return mappedValue;
}
async setMute(mute) {
const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.MUTE, CommandMode.SET, {
value: findValueByMap(DenonClientHeosCli.PROTOCOL.MUTE.VALUES, mute),
});
const mappedValue = findMapByValue(DenonClientHeosCli.PROTOCOL.MUTE.VALUES, response);
if (this.muteUpdateCallback) {
this.muteUpdateCallback(mappedValue);
}
return mappedValue;
}
async getVolume(raceStatus) {
const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.VOLUME, CommandMode.GET, {});
if (raceStatus && !raceStatus.isRunning() && this.volumeUpdateCallback) {
this.volumeUpdateCallback(Number(response));
this.debugLog(`getVolume was late to the party [race id: ${raceStatus.raceId}].`);
}
return Number(response);
}
async setVolume(volume) {
if (volume < 0 || volume > 100) {
throw new Error("Volume must be between 0 and 100");
}
const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.VOLUME, CommandMode.SET, {
value: Math.round(volume).toString(),
});
if (this.volumeUpdateCallback) {
this.volumeUpdateCallback(Number(response));
}
return Number(response);
}
async setVolumeUp(volumeIncrement) {
if (volumeIncrement < 1 || volumeIncrement > 10) {
throw new Error("Volume increment must be between 1 and 10");
}
await this.sendCommand(DenonClientHeosCli.PROTOCOL.VOLUME_UP, CommandMode.SET, {
value: Math.round(volumeIncrement).toString(),
});
}
async setVolumeDown(volumeDecrement) {
if (volumeDecrement < 1 || volumeDecrement > 10) {
throw new Error("Volume decrement must be between 1 and 10");
}
await this.sendCommand(DenonClientHeosCli.PROTOCOL.VOLUME_DOWN, CommandMode.SET, {
value: Math.round(volumeDecrement).toString(),
});
}
async getInput(raceStatus) {
const payload_str = await this.sendCommand(DenonClientHeosCli.PROTOCOL.INPUT, CommandMode.GET, { passPayload: true });
const inputID = DenonClientHeosCli.inputPayloadToInputID(payload_str);
if (raceStatus && !raceStatus.isRunning() && this.inputUpdateCallback) {
this.inputUpdateCallback(inputID);
this.debugLog(`getInput was late to the party [race id: ${raceStatus.raceId}].`);
}
return inputID;
}
static inputPayloadToInputID(payload_str) {
const payload = JSON.parse(payload_str);
if (!payload.sid) {
throw new InvalidResponseException("Payload does not include sid!", undefined, payload_str);
}
if (payload.mid && typeof payload.mid === "string") {
if (payload.mid.startsWith("inputs/")) {
return payload.mid.substring(7);
}
else if (payload.mid.startsWith("cd/")) {
return "cd";
}
}
const sid = Number(payload.sid);
if (sid in DenonClientHeosCli.HEOS_SOURCES) {
if (payload.sid === 1024 && payload.album_id === "1") {
return "airplay";
}
else {
return DenonClientHeosCli.HEOS_SOURCES[sid].inputID;
}
}
return payload.sid.toString();
}
async setInput(inputID) {
const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.INPUT, CommandMode.SET, {
value: inputID,
});
return response;
}
}
//# sourceMappingURL=denonClientHeosCli.js.map