@tplnet/homebridge-denon-heos-audio 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes of this fork. The fork is based on [hov3rcraft/homebridge-denon-heos-audio](https://github.com/hov3rcraft/homebridge-denon-heos-audio) 1.0.0-beta.1.
4
4
 
5
+ ## 1.2.0
6
+
7
+ ### New
8
+
9
+ - **Control protocol check at startup:** the plugin checks which protocols a device supports. If the configured protocol isn't supported (e.g. *Hybrid* or *AVR Control* on a Denon Home speaker), it uses a supported one and logs a warning with what to change, instead of the device showing "No Response". A device configured with *HEOS CLI* that also supports AVR Control gets a hint that *Hybrid* is possible. Devices that are switched off at startup keep their configuration.
10
+ - The plugin settings explain which devices support which protocol.
11
+
12
+ ### Changed
13
+
14
+ - The protocol support of a device is checked on both ports in parallel (faster startup if a device is offline).
15
+
16
+ ## 1.1.2
17
+
18
+ ### Fixed
19
+
20
+ - Switching on (HEOS CLI) also starts the HEOS favorite when the speaker reports "paused" but has nothing to play: if the current media is empty, or if the speaker accepts the play command but playback doesn't start within 3 seconds. Seen on a Denon Home Amp after a streaming session.
21
+
22
+ ## 1.1.1
23
+
24
+ ### Changed
25
+
26
+ - Switching on a speaker that is already playing no longer sends an unnecessary play command.
27
+ - Starting the power on favorite is shown in the normal log (not only in the debug log), e.g. `Nothing to resume, starting HEOS favorite 1.`
28
+
29
+ ### Documentation
30
+
31
+ - README: a HEOS favorite is required for switching on without a source; fixed ports and the *Ciao* mDNS advertiser help if devices appear offline in the Homebridge UI after restarts.
32
+
33
+ ## 1.1.0
34
+
35
+ ### New
36
+
37
+ - **Switching on always plays something (HEOS CLI):** if a speaker has nothing to resume (no active source, e.g. after a restart) or refuses to resume, the first HEOS favorite is started. This makes switching on via scenes and automations reliable. Configurable per device with `powerOnFavorite` (number of the favorite, 0 = disabled).
38
+
39
+ ### Fixed
40
+
41
+ - After switching on or off, the tile could briefly jump back to the old state, because devices report the old state for a moment. The new state is now kept for a few seconds, unless the device reports a different change (e.g. from the HEOS app).
42
+
5
43
  ## 1.0.1
6
44
 
7
45
  ### Changed
package/README.md CHANGED
@@ -10,7 +10,7 @@ Each device appears in the Home app as a TV accessory, so you can:
10
10
  - play, pause and skip tracks with the Remote in Control Center
11
11
 
12
12
  [![npm](https://img.shields.io/npm/v/@tplnet/homebridge-denon-heos-audio)](https://www.npmjs.com/package/@tplnet/homebridge-denon-heos-audio)
13
- [![CI](https://github.com/tplnet/homebridge-denon-heos-audio/actions/workflows/ci.yml/badge.svg)](https://github.com/tplnet/homebridge-denon-heos-audio/actions/workflows/ci.yml)
13
+ [![CI](https://github.com/tplnet/homebridge-denon-heos-audio/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tplnet/homebridge-denon-heos-audio/actions/workflows/ci.yml)
14
14
 
15
15
  > This is a fork of [hov3rcraft/homebridge-denon-heos-audio](https://github.com/hov3rcraft/homebridge-denon-heos-audio) with stability fixes
16
16
  > (no more Homebridge crashes on unexpected device messages), automatic setup of discovered devices, inputs read from the device,
@@ -56,8 +56,15 @@ Not sure what your device supports? Turn on [device discovery](#1-find-your-devi
56
56
  | **Hybrid** | AV receivers and amplifiers with HEOS (e.g. Marantz Cinema series, Denon AVR-X, Denon Home Amp) | Real power on / standby | ✅ | ✅ |
57
57
 
58
58
  **Why does "power" mean "playback" with HEOS CLI?** HEOS speakers have no real standby command. The plugin therefore treats "playing" as on and "stopped/paused" as off.
59
+ Switching on resumes the last source. If there is nothing to resume (e.g. after the speaker was restarted or a streaming session ended), or playback doesn't start within 3 seconds, the first HEOS favorite is started, so switching on also works in scenes and automations. You can choose another favorite or turn this off with `powerOnFavorite`.
59
60
 
60
- **Hybrid** uses AVR Control for power and HEOS CLI for everything else. Pick it whenever discovery reports both protocols.
61
+ > **Requirement:** at least one favorite in the HEOS app (*Music → Favorites*, e.g. a radio station). Without a favorite, a speaker that has nothing to resume can't be switched on from HomeKit.
62
+
63
+ **Hybrid** uses AVR Control for power and HEOS CLI for everything else. Pick it whenever discovery reports both protocols and you want real standby.
64
+
65
+ **Wrong protocol?** At startup the plugin checks which protocols the device supports. If the configured one isn't supported (e.g. *Hybrid* on a Denon Home 150), it uses a supported one instead and logs a warning:
66
+ `"Office Speaker" doesn't support AVR Control (port 23 closed), so "Hybrid" can't be used. Using "HEOS CLI" instead – please change "Control Protocol" in the plugin settings.`
67
+ If a device configured with *HEOS CLI* also supports AVR Control, the log mentions that *Hybrid* is possible. If the device is switched off at startup, the configuration is used unchanged.
61
68
 
62
69
  ## Installation
63
70
 
@@ -126,7 +133,7 @@ Enable **Add Discovered Devices Automatically** in the plugin settings (or set `
126
133
  - the **name** from the HEOS app (e.g. *Patio Music*),
127
134
  - the **control protocol** `HYBRID` if the device supports both protocols, otherwise `HEOSCLI`,
128
135
  - the inputs reported by the device,
129
- - default settings (volume step 2, no volume limit).
136
+ - default settings (volume step 2, no volume limit, first HEOS favorite when switching on without a source).
130
137
 
131
138
  The log confirms every device: `Automatically adding discovered device 'Patio Music' (HYBRID, 192.168.1.149).`
132
139
 
@@ -225,6 +232,7 @@ Repeat these steps for every device. You only have to do it once per device. Upd
225
232
  | `devices[].volumeLimitEnabled` | no | `false` | Enables the volume limit. |
226
233
  | `devices[].volumeLimit` | no | `99` | 0–99. With a limit, 100 % in the Home app equals this value on the device. |
227
234
  | `devices[].volumeStepSize` | no | `2` | 1–10. Volume change per press of the volume buttons in the Remote. |
235
+ | `devices[].powerOnFavorite` | no | `1` | HEOS CLI only: HEOS favorite that is started when the speaker is switched on but has nothing to resume (1 = first favorite in the HEOS app, 0 = disabled). The favorite must exist in the HEOS app. Also used for automatically added devices. |
228
236
  | `devices[].inputs` | no | `[]` | Inputs to show in the Home app, see [Inputs](#inputs). |
229
237
  | `devices[].inputs[].inputID` | yes | | Input ID (depends on the protocol, see below). |
230
238
  | `devices[].inputs[].name` | yes | | Name shown in the Home app. |
@@ -268,7 +276,7 @@ Streaming sources (Spotify, TuneIn, AirPlay, …) are recognised and shown as th
268
276
 
269
277
  | Action | What happens |
270
278
  | --- | --- |
271
- | Power button | Power on / standby (AVR Control, Hybrid) or play / stop (HEOS CLI) |
279
+ | Power button | Power on / standby (AVR Control, Hybrid) or play / stop (HEOS CLI). With HEOS CLI, switching on resumes the last source; if there is none, the first HEOS favorite is started (see `powerOnFavorite`). |
272
280
  | Input picker | Switches the input |
273
281
  | Control Center → Remote → volume buttons on your iPhone | Volume up / down by `volumeStepSize` |
274
282
  | Remote → centre button / play-pause | Play / pause (HEOS CLI, Hybrid) |
@@ -286,12 +294,25 @@ The serial number doesn't match. Use the value from device discovery exactly as
286
294
  **Discovery finds nothing.**
287
295
  SSDP doesn't cross VLANs or guest networks. Docker installations need `network_mode: host`.
288
296
 
297
+ **A speaker doesn't switch on (HEOS CLI).**
298
+ Switching on starts playback. If the speaker has nothing to resume, the plugin starts the HEOS favorite set in `powerOnFavorite` (default: the first one). Make sure at least one favorite exists in the HEOS app (*Music → Favorites*). The debug log shows `starting HEOS favorite …` when this happens.
299
+
289
300
  **The Marantz receiver doesn't react to AVR Control commands.**
290
301
  Enable *Network Control* (sometimes called *IP Control*) in the receiver's network settings so it stays reachable in standby.
291
302
 
292
303
  **The device can't be added in the Home app.**
293
304
  See [The device does not appear in step 4?](#adding-a-device-to-apple-home-step-by-step)
294
305
 
306
+ **Devices appear offline in the Homebridge UI after a restart ("fetch failed"), or commands from the UI don't arrive.**
307
+ Every device runs on its own port. Without a port range, the ports change at every restart and the Homebridge UI may still use the old ones, especially if mDNS doesn't work reliably (e.g. on a Synology NAS: `Failed to create listener for avahi-daemon`). In the Homebridge UI under **Settings → Network**:
308
+ 1. set a **port range** for accessories, e.g. `52100`–`52150` (or `"ports": { "start": 52100, "end": 52150 }` in `config.json`), outside of the ports of your bridges,
309
+ 2. set the **mDNS Advertiser** to **Ciao**,
310
+
311
+ then restart Homebridge **once** and reload the page.
312
+
313
+ **Homebridge doesn't start: `EADDRINUSE: address already in use`.**
314
+ Usually an old Homebridge process is still running (e.g. after several restarts in a row): `ps aux | grep -i [h]omebridge` shows two main `homebridge` processes. Stop Homebridge, end the old process (`kill <pid>`) and start Homebridge again.
315
+
295
316
  **A device is added automatically although I don't want it in HomeKit.**
296
317
  Add its serial number to `excludedSerialNumbers` and restart Homebridge. Then remove it from the Home app.
297
318
 
@@ -304,7 +325,7 @@ Every change is checked against the current Homebridge release and for known vul
304
325
  | Checked | Versions |
305
326
  | --- | --- |
306
327
  | Homebridge | 1.11.4 and 2.5.0 (HAP-NodeJS 0.14.3 / 2.3.0) |
307
- | Node.js | 22, 24 (Homebridge 2.5 supports 22.12+, 24 and 26) |
328
+ | Node.js | 22, 24 and 26 (Homebridge 1.x: up to 24) |
308
329
  | Known vulnerabilities | `npm audit`: 0 findings (runtime and development dependencies) |
309
330
 
310
331
  *Last checked: November 2026.* The GitHub workflow repeats these checks on every push and **weekly**, so new Homebridge releases and newly published CVEs are noticed even without code changes.
@@ -61,6 +61,7 @@
61
61
  },
62
62
  "controlProtocol": {
63
63
  "title": "Control Protocol",
64
+ "description": "HEOS CLI: speakers like the Denon Home series (on/off = play/stop). Hybrid: devices that also support AVR Control, e.g. AV receivers and the Denon Home Amp (real standby). AVR Control: receivers without HEOS. Device discovery lists the supported protocols in the log; an unsupported protocol is replaced automatically at startup (with a warning).",
64
65
  "type": "string",
65
66
  "oneOf": [
66
67
  {
@@ -102,6 +103,15 @@
102
103
  "maximum": 10,
103
104
  "default": 2
104
105
  },
106
+ "powerOnFavorite": {
107
+ "title": "Power On Favorite",
108
+ "description": "HEOS CLI only: if a speaker has nothing to resume when it is switched on, this HEOS favorite is started (1 = first favorite in the HEOS app). 0 = disabled.",
109
+ "type": "integer",
110
+ "required": false,
111
+ "minimum": 0,
112
+ "maximum": 50,
113
+ "default": 1
114
+ },
105
115
  "inputs": {
106
116
  "title": "Inputs",
107
117
  "description": "The list of inputs to your device. If you configure inputs here, those inputs will become selectable in the Home app.",
package/dist/config.d.ts CHANGED
@@ -14,5 +14,6 @@ export interface DeviceConfig {
14
14
  volumeLimitEnabled?: boolean;
15
15
  volumeLimit?: number;
16
16
  volumeStepSize?: number;
17
+ powerOnFavorite?: number;
17
18
  inputs?: InputConfig[];
18
19
  }
@@ -55,6 +55,11 @@ export interface IDenonClient {
55
55
  * Inputs that are only used to show the current source (e.g. streaming services), if the inputs could be read from the device.
56
56
  */
57
57
  readonly displayOnlyInputs: DefaultInput[];
58
+ /**
59
+ * HEOS favorite that is started when switching on a HEOS CLI device that has nothing to resume (0 = disabled).
60
+ * Not used by protocols with a real power state.
61
+ */
62
+ setPowerOnFavorite?(preset: number, infoLog?: (message: string) => void): void;
58
63
  }
59
64
  export declare abstract class DenonClient implements IDenonClient {
60
65
  readonly serialNumber: string;
@@ -63,6 +63,14 @@ export declare class DenonClientHeosCli extends DenonClient {
63
63
  };
64
64
  };
65
65
  };
66
+ PLAY_PRESET: {
67
+ SET: {
68
+ COMMAND: string;
69
+ PARAMS: string;
70
+ EXP_RES: RegExp;
71
+ TIMEOUT: number;
72
+ };
73
+ };
66
74
  PLAY_NEXT: {
67
75
  SET: {
68
76
  COMMAND: string;
@@ -188,6 +196,12 @@ export declare class DenonClientHeosCli extends DenonClient {
188
196
  static readonly DEFAULT_INPUT_SOURCES: DefaultInput[];
189
197
  readonly displayOnlyInputs: DefaultInput[];
190
198
  private player_id;
199
+ /** HEOS favorite (1, 2, …) that is started when switching on without something to resume; 0 = disabled. */
200
+ private powerOnFavorite;
201
+ private static readonly PLAY_VERIFY_CHECKS;
202
+ private playVerifyIntervalMs;
203
+ /** Log for actions the user should see without debug logging (e.g. starting the power on favorite). */
204
+ private infoLog;
191
205
  private player_name;
192
206
  constructor(serialNumber: string, name: string, host: string, connect_timeout: number, response_timeout: number, debugLogCallback?: (message: string, ...parameters: any[]) => void, powerUpdateCallback?: (power: boolean) => void, muteUpdateCallback?: (mute: boolean) => void, volumeUpdateCallback?: (volume: number) => void, inputUpdateCallback?: (input: string) => void);
193
207
  private findPlayerId;
@@ -204,7 +218,18 @@ export declare class DenonClientHeosCli extends DenonClient {
204
218
  setPlayNext(): Promise<void>;
205
219
  setPlayPrevious(): Promise<void>;
206
220
  getPower(raceStatus?: RaceStatus): Promise<boolean>;
221
+ setPowerOnFavorite(preset: number, infoLog?: (message: string) => void): void;
222
+ /**
223
+ * Switching on means starting playback. If there is nothing to resume (play state "unknown", e.g. no source
224
+ * since the speaker was started) or the device refuses to play, the configured HEOS favorite is started instead.
225
+ */
207
226
  setPower(power: boolean): Promise<boolean>;
227
+ private powerOn;
228
+ /**
229
+ * True if the player has no current media to resume (e.g. an empty entry after a streaming session ended).
230
+ */
231
+ private hasNoMedia;
232
+ private playFavorite;
208
233
  getMute(raceStatus?: RaceStatus): Promise<boolean>;
209
234
  setMute(mute: boolean): Promise<boolean>;
210
235
  getVolume(raceStatus?: RaceStatus): Promise<number>;
@@ -66,6 +66,15 @@ export class DenonClientHeosCli extends DenonClient {
66
66
  },
67
67
  },
68
68
  },
69
+ PLAY_PRESET: {
70
+ // plays a HEOS favorite (preset position starting at 1)
71
+ SET: {
72
+ COMMAND: "browse/play_preset",
73
+ PARAMS: "?pid=[PID]&preset=[VALUE]",
74
+ EXP_RES: /preset=(\d+)/,
75
+ TIMEOUT: 6000,
76
+ },
77
+ },
69
78
  PLAY_NEXT: {
70
79
  SET: {
71
80
  COMMAND: "player/play_next",
@@ -237,6 +246,12 @@ export class DenonClientHeosCli extends DenonClient {
237
246
  static DEFAULT_INPUT_SOURCES = [...DenonClientHeosCli.DISPLAY_ONLY_INPUT_SOURCES, ...DenonClientHeosCli.INPUT_SOURCES];
238
247
  displayOnlyInputs = DenonClientHeosCli.DISPLAY_ONLY_INPUT_SOURCES;
239
248
  player_id;
249
+ /** HEOS favorite (1, 2, …) that is started when switching on without something to resume; 0 = disabled. */
250
+ powerOnFavorite = 1;
251
+ static PLAY_VERIFY_CHECKS = 3;
252
+ playVerifyIntervalMs = 1000;
253
+ /** Log for actions the user should see without debug logging (e.g. starting the power on favorite). */
254
+ infoLog = (message) => this.debugLog(message);
240
255
  player_name;
241
256
  constructor(serialNumber, name, host, connect_timeout, response_timeout, debugLogCallback, powerUpdateCallback, muteUpdateCallback, volumeUpdateCallback, inputUpdateCallback) {
242
257
  super(serialNumber, name, {
@@ -422,12 +437,87 @@ export class DenonClientHeosCli extends DenonClient {
422
437
  }
423
438
  return isPlaying[playing];
424
439
  }
440
+ setPowerOnFavorite(preset, infoLog) {
441
+ this.powerOnFavorite = preset;
442
+ if (infoLog) {
443
+ this.infoLog = infoLog;
444
+ }
445
+ }
446
+ /**
447
+ * Switching on means starting playback. If there is nothing to resume (play state "unknown", e.g. no source
448
+ * since the speaker was started) or the device refuses to play, the configured HEOS favorite is started instead.
449
+ */
425
450
  async setPower(power) {
426
- const newPlaying = await this.setPlaying(power ? Playing.PLAY : Playing.STOP);
427
- if (this.powerUpdateCallback) {
428
- this.powerUpdateCallback(isPlaying[newPlaying]);
451
+ let playing;
452
+ if (!power) {
453
+ playing = isPlaying[await this.setPlaying(Playing.STOP)];
429
454
  }
430
- return isPlaying[newPlaying];
455
+ else {
456
+ playing = await this.powerOn();
457
+ }
458
+ this.powerUpdateCallback?.(playing);
459
+ return playing;
460
+ }
461
+ async powerOn() {
462
+ const PLAY = DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES.PLAY.VALUE;
463
+ const rawState = await this.sendCommand(DenonClientHeosCli.PROTOCOL.PLAY_STATE, CommandMode.GET, {});
464
+ if (rawState === PLAY) {
465
+ this.debugLog("Already playing, nothing to do.");
466
+ return true;
467
+ }
468
+ const favorite = this.powerOnFavorite;
469
+ if (favorite > 0) {
470
+ if (rawState === DenonClientHeosCli.PROTOCOL.PLAY_STATE.VALUES.UNKNOWN.VALUE) {
471
+ this.infoLog(`Nothing to resume, starting HEOS favorite ${favorite}.`);
472
+ return this.playFavorite(favorite);
473
+ }
474
+ if (await this.hasNoMedia()) {
475
+ this.infoLog(`Nothing to resume (no current media), starting HEOS favorite ${favorite}.`);
476
+ return this.playFavorite(favorite);
477
+ }
478
+ }
479
+ try {
480
+ await this.setPlaying(Playing.PLAY);
481
+ }
482
+ catch (error) {
483
+ if (!(error instanceof CommandFailedException) || favorite <= 0) {
484
+ throw error;
485
+ }
486
+ this.infoLog(`The speaker refused to resume playback, starting HEOS favorite ${favorite}.`);
487
+ return this.playFavorite(favorite);
488
+ }
489
+ if (favorite <= 0) {
490
+ return true;
491
+ }
492
+ // Some devices accept "play" without having anything to play (they stay paused), so check that playback started.
493
+ for (let check = 0; check < DenonClientHeosCli.PLAY_VERIFY_CHECKS; check++) {
494
+ await new Promise((resolve) => setTimeout(resolve, this.playVerifyIntervalMs));
495
+ if ((await this.sendCommand(DenonClientHeosCli.PROTOCOL.PLAY_STATE, CommandMode.GET, {})) === PLAY) {
496
+ return true;
497
+ }
498
+ }
499
+ this.infoLog(`Playback did not start, starting HEOS favorite ${favorite}.`);
500
+ return this.playFavorite(favorite);
501
+ }
502
+ /**
503
+ * True if the player has no current media to resume (e.g. an empty entry after a streaming session ended).
504
+ */
505
+ async hasNoMedia() {
506
+ try {
507
+ const payload = JSON.parse(await this.sendCommand(DenonClientHeosCli.PROTOCOL.INPUT, CommandMode.GET, { passPayload: true }));
508
+ if (typeof payload?.mid === "string" && payload.mid.startsWith("inputs/")) {
509
+ return false; // a physical input can always be resumed
510
+ }
511
+ return ["song", "station", "artist", "album"].every((field) => !payload?.[field]);
512
+ }
513
+ catch (error) {
514
+ this.debugLog("Could not read the current media:", error instanceof Error ? error.message : error);
515
+ return false;
516
+ }
517
+ }
518
+ async playFavorite(preset) {
519
+ await this.sendCommand(DenonClientHeosCli.PROTOCOL.PLAY_PRESET, CommandMode.SET, { value: preset.toString() });
520
+ return true;
431
521
  }
432
522
  async getMute(raceStatus) {
433
523
  const response = await this.sendCommand(DenonClientHeosCli.PROTOCOL.MUTE, CommandMode.GET, {});
@@ -30,15 +30,9 @@ function checkProtocolSupportAtPort(host, port, debugLogCallback) {
30
30
  });
31
31
  }
32
32
  export async function checkProtocolSupport(host, debugLogCallback) {
33
- const supportedProtocols = [];
34
- for (const controlMode of Object.values(ControlMode).filter((value) => typeof value === "number")) {
35
- if (controlMode >= 0) {
36
- const supported = await checkProtocolSupportAtPort(host, controlMode, debugLogCallback);
37
- if (supported) {
38
- supportedProtocols.push(controlMode);
39
- }
40
- }
41
- }
42
- return supportedProtocols;
33
+ const modes = Object.values(ControlMode).filter((value) => typeof value === "number" && value >= 0);
34
+ // check all ports in parallel, so an unreachable device delays the startup only once
35
+ const results = await Promise.all(modes.map((mode) => checkProtocolSupportAtPort(host, mode, debugLogCallback)));
36
+ return modes.filter((_mode, index) => results[index]);
43
37
  }
44
38
  //# sourceMappingURL=denonProtocol.js.map
@@ -30,6 +30,11 @@ export declare class DenonAudioPlatform implements DynamicPlatformPlugin {
30
30
  * (HomeKit only allows one television accessory per bridge).
31
31
  */
32
32
  private publishNewAccessory;
33
+ /**
34
+ * Checks the configured control protocol against the protocols the device supports and falls back to a supported
35
+ * one if necessary (with a warning). If the device can't be reached, the configuration is used unchanged.
36
+ */
37
+ private withCheckedProtocol;
33
38
  /**
34
39
  * Adds a discovered device as new accessory, unless it is configured manually, excluded or already set up.
35
40
  */
package/dist/platform.js CHANGED
@@ -3,6 +3,7 @@ import * as CustomLogging from "./customLogging.js";
3
3
  import { PLATFORM_NAME, PLUGIN_NAME } from "./settings.js";
4
4
  import * as DenonProtocol from "./denonProtocol.js";
5
5
  import { buildDiscoveredDeviceConfig } from "./discovery.js";
6
+ import { checkControlProtocol } from "./protocolCheck.js";
6
7
  import { HeosDiscovery } from "./heosDiscovery.js";
7
8
  const MINUTE = 60 * 1000;
8
9
  export class DenonAudioPlatform {
@@ -77,9 +78,9 @@ export class DenonAudioPlatform {
77
78
  // the accessory already exists
78
79
  this.log.info("Restoring existing accessory from cache:", existingAccessory.displayName);
79
80
  // create the accessory handler for the restored accessory
80
- this.createAccessoryHandler(existingAccessory, deviceConfig)
81
- ?.setupInputs()
82
- .catch((error) => this.log.error(`Could not set up the inputs of '${deviceConfig.name}':`, error));
81
+ this.withCheckedProtocol(deviceConfig)
82
+ .then((checkedConfig) => this.createAccessoryHandler(existingAccessory, checkedConfig)?.setupInputs())
83
+ .catch((error) => this.log.error(`Could not set up '${deviceConfig.name}':`, error));
83
84
  // after it has been restored, the existing accessory should be removed form the uuid list
84
85
  existingAccessoriesUuids = existingAccessoriesUuids.filter((uuid) => uuid !== existingAccessory.UUID);
85
86
  }
@@ -116,18 +117,38 @@ export class DenonAudioPlatform {
116
117
  * Sets up a new accessory and publishes it as external accessory
117
118
  * (HomeKit only allows one television accessory per bridge).
118
119
  */
119
- publishNewAccessory(deviceConfig, accessory) {
120
+ publishNewAccessory(deviceConfig, accessory, supportedModes) {
120
121
  // store a copy of the device object in the `accessory.context`
121
122
  accessory.context.device = deviceConfig;
122
- const handler = this.createAccessoryHandler(accessory, deviceConfig);
123
- if (!handler) {
124
- return;
123
+ this.withCheckedProtocol(deviceConfig, supportedModes)
124
+ .then(async (checkedConfig) => {
125
+ const handler = this.createAccessoryHandler(accessory, checkedConfig);
126
+ if (!handler) {
127
+ return;
128
+ }
129
+ // the inputs are read from the device first, as the services of a published accessory should not change anymore
130
+ await handler.setupInputs().catch((error) => this.log.error(`Could not set up the inputs of '${deviceConfig.name}':`, error));
131
+ this.api.publishExternalAccessories(PLUGIN_NAME, [accessory]);
132
+ })
133
+ .catch((error) => this.log.error(`Could not set up '${deviceConfig.name}':`, error));
134
+ }
135
+ /**
136
+ * Checks the configured control protocol against the protocols the device supports and falls back to a supported
137
+ * one if necessary (with a warning). If the device can't be reached, the configuration is used unchanged.
138
+ */
139
+ async withCheckedProtocol(deviceConfig, knownSupportedModes) {
140
+ const supportedModes = knownSupportedModes ?? (await DenonProtocol.checkProtocolSupport(deviceConfig.ip, this.log.debug.bind(this.log)));
141
+ const result = checkControlProtocol(deviceConfig.name, deviceConfig.controlProtocol, supportedModes);
142
+ if (result.warning) {
143
+ this.log.warn(result.warning);
144
+ }
145
+ if (result.hint) {
146
+ this.log.info(result.hint);
147
+ }
148
+ if (supportedModes.length === 0) {
149
+ this.log.debug(`Could not check the protocols of '${deviceConfig.name}' (${deviceConfig.ip} not reachable). Using the configuration.`);
125
150
  }
126
- // the inputs are read from the device first, as the services of a published accessory should not change anymore
127
- handler
128
- .setupInputs()
129
- .catch((error) => this.log.error(`Could not set up the inputs of '${deviceConfig.name}':`, error))
130
- .finally(() => this.api.publishExternalAccessories(PLUGIN_NAME, [accessory]));
151
+ return result.protocol === deviceConfig.controlProtocol ? deviceConfig : { ...deviceConfig, controlProtocol: result.protocol };
131
152
  }
132
153
  /**
133
154
  * Adds a discovered device as new accessory, unless it is configured manually, excluded or already set up.
@@ -147,7 +168,7 @@ export class DenonAudioPlatform {
147
168
  }
148
169
  this.activeSerialNumbers.add(serialNumber);
149
170
  this.log.info(`Automatically adding discovered device '${deviceConfig.name}' (${deviceConfig.controlProtocol}, ${ip}).`);
150
- this.publishNewAccessory(deviceConfig, new this.api.platformAccessory(deviceConfig.name, this.api.hap.uuid.generate(serialNumber)));
171
+ this.publishNewAccessory(deviceConfig, new this.api.platformAccessory(deviceConfig.name, this.api.hap.uuid.generate(serialNumber)), supportedModes);
151
172
  }
152
173
  /**
153
174
  * Creates the accessory handler. Configuration errors are logged instead of being thrown,
@@ -11,6 +11,8 @@ export declare class DenonAudioAccessory {
11
11
  private static readonly API_CONNECT_TIMEOUT;
12
12
  private static readonly API_RESPONSE_TIMEOUT;
13
13
  private static readonly DEFAULT_VOLUME_STEP_SIZE;
14
+ private static readonly DEFAULT_POWER_ON_FAVORITE;
15
+ private static readonly POWER_STATE_SETTLE_TIME;
14
16
  private static readonly INPUT_QUERY_TIMEOUT;
15
17
  private static readonly DEFAULT_VOLUME_LIMIT;
16
18
  private readonly platform;
@@ -35,6 +37,7 @@ export declare class DenonAudioAccessory {
35
37
  private lastSetVolume;
36
38
  private targetMediaState;
37
39
  private volumeChangeOngoing;
40
+ private pendingActive;
38
41
  private playingChangeOngoing;
39
42
  constructor(platform: DenonAudioPlatform, accessory: PlatformAccessory, config: DeviceConfig, log: Logger);
40
43
  /**
@@ -15,6 +15,9 @@ export class DenonAudioAccessory {
15
15
  static API_CONNECT_TIMEOUT = 5 * 1000;
16
16
  static API_RESPONSE_TIMEOUT = 1 * 1000;
17
17
  static DEFAULT_VOLUME_STEP_SIZE = 2;
18
+ static DEFAULT_POWER_ON_FAVORITE = 1;
19
+ // after switching, devices report the old state for a moment; HomeKit gets the new state during this time
20
+ static POWER_STATE_SETTLE_TIME = 5 * 1000;
18
21
  // includes the time to connect to the device and to look up the player id
19
22
  static INPUT_QUERY_TIMEOUT = 15 * 1000;
20
23
  static DEFAULT_VOLUME_LIMIT = 99;
@@ -40,6 +43,7 @@ export class DenonAudioAccessory {
40
43
  lastSetVolume;
41
44
  targetMediaState;
42
45
  volumeChangeOngoing = false;
46
+ pendingActive;
43
47
  playingChangeOngoing = false;
44
48
  constructor(platform, accessory, config, log) {
45
49
  log.debug("Initializing DenonAudioAccessory...");
@@ -103,6 +107,11 @@ export class DenonAudioAccessory {
103
107
  this.speakerService.setCharacteristic(this.platform.Characteristic.VolumeControlType, this.platform.Characteristic.VolumeControlType.ABSOLUTE);
104
108
  // choose appropriate client
105
109
  this.denonClient = new DenonProtocol.CLIENT_MAP[this.controlMode](this.serialNumber, this.name, this.ip, DenonAudioAccessory.API_CONNECT_TIMEOUT, DenonAudioAccessory.API_RESPONSE_TIMEOUT, this.log.debug.bind(this.log), this.callbackActive.bind(this), this.callbackMute.bind(this), this.callbackVolume.bind(this), this.callbackInput.bind(this));
110
+ const powerOnFavorite = config.powerOnFavorite ?? DenonAudioAccessory.DEFAULT_POWER_ON_FAVORITE;
111
+ if (!Number.isInteger(powerOnFavorite) || powerOnFavorite < 0) {
112
+ throw new Error("Power on favorite must be 0 (disabled) or the number of a HEOS favorite (1, 2, …)");
113
+ }
114
+ this.denonClient.setPowerOnFavorite?.(powerOnFavorite, (message) => this.log.info(message));
106
115
  this.configuredInputs = config.inputs ?? [];
107
116
  this.unknownInput = new ConfiguredInput("unknown", "Unknown Input", false, 0, false);
108
117
  this.log.info("Finished initializing accessory.");
@@ -206,6 +215,11 @@ export class DenonAudioAccessory {
206
215
  * These are sent when HomeKit wants to know the current state of the accessory, for example, checking if a Light bulb is on.
207
216
  */
208
217
  async getActive() {
218
+ if (this.pendingActive && Date.now() < this.pendingActive.until) {
219
+ this.log.debug(`getActive for ${this.name}. [recently set to ${this.pendingActive.value}]`);
220
+ return this.pendingActive.value;
221
+ }
222
+ this.pendingActive = undefined;
209
223
  return this.queryDevice("getActive", "power status", (raceStatus) => this.denonClient.getPower(raceStatus));
210
224
  }
211
225
  async getActiveIdentifier() {
@@ -297,10 +311,13 @@ export class DenonAudioAccessory {
297
311
  }
298
312
  async setActive(newValue) {
299
313
  this.log.debug(`setActive for ${this.name} set to ${newValue}`);
314
+ const active = newValue === this.platform.Characteristic.Active.ACTIVE;
300
315
  try {
301
- await this.denonClient.setPower(newValue === this.platform.Characteristic.Active.ACTIVE);
316
+ await this.denonClient.setPower(active);
317
+ this.pendingActive = { value: active, until: Date.now() + DenonAudioAccessory.POWER_STATE_SETTLE_TIME };
302
318
  }
303
319
  catch (error) {
320
+ this.pendingActive = undefined;
304
321
  this.log.error(`An error occured while setting power status for ${this.name}.`, error);
305
322
  throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
306
323
  }
@@ -525,6 +542,10 @@ export class DenonAudioAccessory {
525
542
  * Handles characteristic updates from the API
526
543
  */
527
544
  callbackActive(active) {
545
+ if (this.pendingActive && this.pendingActive.value !== active) {
546
+ // changed on the device (e.g. in the HEOS app) - the device state wins
547
+ this.pendingActive = undefined;
548
+ }
528
549
  this.tvService.updateCharacteristic(this.platform.Characteristic.Active, active);
529
550
  }
530
551
  callbackMute(mute) {
@@ -0,0 +1,20 @@
1
+ import { ControlMode } from "./controlMode.js";
2
+ import type { DeviceConfig } from "./config.js";
3
+ type ProtocolName = DeviceConfig["controlProtocol"];
4
+ export interface ProtocolCheckResult {
5
+ /** The protocol to use. */
6
+ protocol: ProtocolName;
7
+ /** Warning for the log if the configured protocol can't be used. */
8
+ warning?: string;
9
+ /** Hint for the log (e.g. a better protocol is available). */
10
+ hint?: string;
11
+ }
12
+ /**
13
+ * Checks the configured control protocol against the protocols the device supports.
14
+ *
15
+ * - The device supports everything the protocol needs: keep it (with a hint if Hybrid would be possible instead of HEOS CLI).
16
+ * - The device doesn't support it, but another one: use the best supported protocol and return a warning.
17
+ * - The device can't be reached (nothing supported): keep the configuration, the device may just be switched off.
18
+ */
19
+ export declare function checkControlProtocol(deviceName: string, configured: ProtocolName, supportedModes: ControlMode[]): ProtocolCheckResult;
20
+ export {};
@@ -0,0 +1,46 @@
1
+ import { ControlMode, CONTROL_MODE_BY_NAME } from "./controlMode.js";
2
+ import { chooseControlProtocol } from "./discovery.js";
3
+ /** Ports (= control modes) a protocol needs. */
4
+ const REQUIRED_MODES = {
5
+ AVRCONTROL: [ControlMode.AVRCONTROL],
6
+ HEOSCLI: [ControlMode.HEOSCLI],
7
+ HYBRID: [ControlMode.AVRCONTROL, ControlMode.HEOSCLI],
8
+ };
9
+ const PROTOCOL_LABEL = {
10
+ AVRCONTROL: "AVR Control",
11
+ HEOSCLI: "HEOS CLI",
12
+ HYBRID: "Hybrid",
13
+ };
14
+ /**
15
+ * Checks the configured control protocol against the protocols the device supports.
16
+ *
17
+ * - The device supports everything the protocol needs: keep it (with a hint if Hybrid would be possible instead of HEOS CLI).
18
+ * - The device doesn't support it, but another one: use the best supported protocol and return a warning.
19
+ * - The device can't be reached (nothing supported): keep the configuration, the device may just be switched off.
20
+ */
21
+ export function checkControlProtocol(deviceName, configured, supportedModes) {
22
+ if (!(configured in CONTROL_MODE_BY_NAME) || supportedModes.length === 0) {
23
+ return { protocol: configured };
24
+ }
25
+ const missing = REQUIRED_MODES[configured].filter((mode) => !supportedModes.includes(mode));
26
+ if (missing.length === 0) {
27
+ if (configured === "HEOSCLI" && supportedModes.includes(ControlMode.AVRCONTROL)) {
28
+ return {
29
+ protocol: configured,
30
+ hint: `"${deviceName}" also supports AVR Control. "Hybrid" would switch it to real standby instead of only stopping the playback.`,
31
+ };
32
+ }
33
+ return { protocol: configured };
34
+ }
35
+ const fallback = chooseControlProtocol(supportedModes);
36
+ if (!fallback) {
37
+ return { protocol: configured };
38
+ }
39
+ const missingText = missing.map((mode) => `${ControlMode[mode] === "AVRCONTROL" ? "AVR Control" : "HEOS CLI"} (port ${mode} closed)`).join(", ");
40
+ return {
41
+ protocol: fallback,
42
+ warning: `"${deviceName}" doesn't support ${missingText}, so "${PROTOCOL_LABEL[configured]}" can't be used. ` +
43
+ `Using "${PROTOCOL_LABEL[fallback]}" instead – please change "Control Protocol" in the plugin settings.`,
44
+ };
45
+ }
46
+ //# sourceMappingURL=protocolCheck.js.map
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@tplnet/homebridge-denon-heos-audio",
3
3
  "displayName": "Homebridge Denon Heos Audio (tplnet)",
4
4
  "type": "module",
5
- "version": "1.0.1",
5
+ "version": "1.2.0",
6
6
  "description": "Control your Denon and Marantz HEOS speakers, amplifiers and AVRs via AVR Control and/or HEOS CLI",
7
7
  "author": "tplnet",
8
8
  "contributors": [