diff --git a/HOWTO.md b/HOWTO.md index b725988..2705602 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -506,6 +506,7 @@ events: Volume actions are also available as `denon.volume_up` and `denon.volume_down`. Each call sends five volume steps to the receiver. +`denon.volume_mute` explicitly mutes the main output. The Denon receiver connection is also configured in `events.yaml`. Power actions use Denon's HTTP control endpoint; set **Settings → Network → Network @@ -534,4 +535,27 @@ After changing `events.yaml`, restart the listener: ```bash sudo systemctl restart zigbee-events.service ``` + +### HEOS favorite stations + +The receiver's HEOS Favorites can be discovered and played through its CLI +service on port 1255. The library resolves the player and Favorites source IDs +dynamically: + +```bash +python3 lib_heos.py list +python3 lib_heos.py play 1 +python3 lib_heos.py play "NPO Radio 2" +``` + +Favorite numbers are the current 1-based positions shown by `list`. Names are +matched case-insensitively. Playing a favorite starts playback on the first +HEOS player returned by the receiver. + +`denon.next` checks the AVR power state before changing stations. In standby it +starts favorite 1. When powered on, it advances only if the current station is +in HEOS Favorites, wrapping the final favorite back to the first. It does +nothing when another source or a non-favorite station is playing. +`denon.previous` has the same rules in reverse and wraps favorite 1 back to the +final favorite. - [RRDtool graph elements](https://oss.oetiker.ch/rrdtool/doc/rrdgraph_graph.en.html) diff --git a/events.yaml b/events.yaml index a04dc09..8a96321 100644 --- a/events.yaml +++ b/events.yaml @@ -32,9 +32,12 @@ devices: enabled: true events: single_button_1: denon.on - single_button_2: denon.off - single_button_3: denon.previous - single_button_4: denon.next + long_button_1: denon.off + single_button_2: denon.previous + long_button_2: denon.next + single_button_3: denon.volume_down + long_button_3: denon.volume_mute + single_button_4: denon.volume_up - name: Device 2 topic: zigbee2mqtt/CHANGE_ME_DEVICE_2 diff --git a/lib_denon.py b/lib_denon.py index f305ef4..78f5146 100644 --- a/lib_denon.py +++ b/lib_denon.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import socket import ssl import sys from collections.abc import Callable @@ -10,6 +11,8 @@ from urllib.error import URLError from urllib.parse import quote from urllib.request import Request, urlopen +import lib_heos + _host = "192.168.178.177" _port = 8080 @@ -54,12 +57,44 @@ def off() -> None: _command("PWSTANDBY") +def _power_state() -> str: + """Return ON or STANDBY using Denon's control-protocol status query.""" + try: + with socket.create_connection((_host, 23), timeout=_timeout) as connection: + connection.settimeout(_timeout) + connection.sendall(b"PW?\r") + with connection.makefile("r", encoding="ascii", newline="\r") as stream: + while line := stream.readline(): + response = line.strip() + if response.startswith("PW"): + state = response[2:] + if state in {"ON", "STANDBY"}: + return state + except OSError as error: + raise RuntimeError(f"Cannot query Denon power state: {error}") from error + raise RuntimeError("Denon did not return a valid power state") + + def next() -> None: - """Select the next preset or source.""" + """Play the next HEOS favorite according to the receiver's power state.""" + lib_heos.configure(_host, timeout=_timeout) + if _power_state() == "STANDBY": + lib_heos.play_favorite(1) + return + # Deliberately do nothing when the receiver is on but its current media is + # not one of the configured HEOS favorites. + lib_heos.next_favorite() def previous() -> None: - """Select the previous preset or source.""" + """Play the previous HEOS favorite according to the receiver's power state.""" + lib_heos.configure(_host, timeout=_timeout) + if _power_state() == "STANDBY": + lib_heos.play_favorite(1) + return + # Deliberately do nothing when the receiver is on but its current media is + # not one of the configured HEOS favorites. + lib_heos.previous_favorite() def volume_up() -> None: @@ -74,12 +109,25 @@ def volume_down() -> None: _command("MVDOWN") +def volume_mute() -> None: + """Mute the volume.""" + _command("MUON") + + def main() -> int: """Run a receiver action from the command line.""" parser = argparse.ArgumentParser(description="Control the Denon receiver") parser.add_argument( "action", - choices=("on", "off", "next", "previous", "volume-up", "volume-down"), + choices=( + "on", + "off", + "next", + "previous", + "volume-up", + "volume-down", + "volume-mute", + ), ) parser.add_argument("--host", default=_host, help=f"receiver address (default: {_host})") parser.add_argument("--port", type=int, default=_port, help=f"HTTP port (default: {_port})") @@ -116,10 +164,11 @@ def main() -> int: "previous": previous, "volume-up": volume_up, "volume-down": volume_down, + "volume-mute": volume_mute, } try: actions[args.action]() - except (OSError, URLError) as error: + except (OSError, URLError, RuntimeError, lib_heos.HeosError) as error: print(f"Denon {args.action} failed: {error}", file=sys.stderr) return 1 return 0 diff --git a/lib_heos.py b/lib_heos.py new file mode 100644 index 0000000..76c28cb --- /dev/null +++ b/lib_heos.py @@ -0,0 +1,202 @@ +"""Discover and play HEOS favorite stations through the HEOS CLI service.""" + +from __future__ import annotations + +import argparse +import json +import socket +import sys +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + + +_host = "192.168.178.177" +_port = 1255 +_timeout = 5.0 + + +class HeosError(RuntimeError): + """Raised when HEOS rejects a command or returns an invalid response.""" + + +@dataclass(frozen=True) +class Favorite: + position: int + name: str + media_id: str + + +def configure(host: str, port: int = 1255, timeout: float = 5.0) -> None: + """Configure the HEOS device used by subsequent calls.""" + global _host, _port, _timeout + _host = host + _port = port + _timeout = timeout + + +def _command(path: str, **parameters: object) -> dict[str, Any]: + # HEOS expects the comma in range values literally (e.g. range=0,100). + query = urlencode(parameters, safe=",") + command = f"heos://{path}" + (f"?{query}" if query else "") + try: + with socket.create_connection((_host, _port), timeout=_timeout) as connection: + connection.settimeout(_timeout) + connection.sendall((command + "\r\n").encode("utf-8")) + with connection.makefile("r", encoding="utf-8", newline="\n") as stream: + while line := stream.readline(): + try: + response = json.loads(line) + except json.JSONDecodeError as error: + raise HeosError(f"Invalid HEOS response: {line.rstrip()!r}") from error + heos = response.get("heos", {}) + if str(heos.get("command", "")).strip() != path: + continue + if heos.get("result") != "success": + raise HeosError(str(heos.get("message", "HEOS command failed"))) + message = str(heos.get("message", "")) + if "command under process" in message: + continue + return response + except (OSError, TimeoutError) as error: + raise HeosError(f"Cannot communicate with HEOS at {_host}:{_port}: {error}") from error + raise HeosError(f"HEOS closed the connection without answering {path!r}") + + +def _player_id() -> str: + response = _command("player/get_players") + players = response.get("payload", []) + if not isinstance(players, list) or not players: + raise HeosError("No HEOS players found") + return str(players[0]["pid"]) + + +def _favorites_source_id() -> str: + response = _command("browse/get_music_sources") + sources = response.get("payload", []) + if not isinstance(sources, list): + raise HeosError("HEOS returned an invalid music-source list") + for source in sources: + if str(source.get("name", "")).casefold() == "favorites": + if source.get("available") == "false": + raise HeosError("The HEOS Favorites source is unavailable") + return str(source["sid"]) + raise HeosError("HEOS Favorites source not found") + + +def list_favorites() -> list[Favorite]: + """Return the currently configured HEOS favorite stations.""" + response = _command("browse/browse", sid=_favorites_source_id(), range="0,100") + payload = response.get("payload", []) + if not isinstance(payload, list): + raise HeosError("HEOS returned an invalid Favorites list") + return [ + Favorite(position=index, name=str(item["name"]), media_id=str(item.get("mid", ""))) + for index, item in enumerate(payload, start=1) + if item.get("playable") == "yes" + ] + + +def _find_current_favorite(favorites: list[Favorite], payload: object) -> Favorite | None: + if not isinstance(payload, dict) or payload.get("type") != "station": + return None + current_ids = { + str(payload.get(key, "")).strip() + for key in ("album_id", "mid") + if payload.get(key) + } + for favorite in favorites: + if favorite.media_id and favorite.media_id in current_ids: + return favorite + station = str(payload.get("station", "")).strip().casefold() + if station: + return next((item for item in favorites if item.name.casefold() == station), None) + return None + + +def current_favorite() -> Favorite | None: + """Return the playing HEOS favorite, or None for other/no playback.""" + favorites = list_favorites() + player_id = _player_id() + response = _command("player/get_now_playing_media", pid=player_id) + return _find_current_favorite(favorites, response.get("payload")) + + +def next_favorite() -> Favorite | None: + """Play the favorite after the current one, wrapping at the end.""" + favorites = list_favorites() + if not favorites: + raise HeosError("No HEOS favorites configured") + player_id = _player_id() + response = _command("player/get_now_playing_media", pid=player_id) + current = _find_current_favorite(favorites, response.get("payload")) + if current is None: + return None + current_index = favorites.index(current) + selected = favorites[(current_index + 1) % len(favorites)] + _command("browse/play_preset", pid=player_id, preset=selected.position) + return selected + + +def previous_favorite() -> Favorite | None: + """Play the favorite before the current one, wrapping at the start.""" + favorites = list_favorites() + if not favorites: + raise HeosError("No HEOS favorites configured") + player_id = _player_id() + response = _command("player/get_now_playing_media", pid=player_id) + current = _find_current_favorite(favorites, response.get("payload")) + if current is None: + return None + current_index = favorites.index(current) + selected = favorites[(current_index - 1) % len(favorites)] + _command("browse/play_preset", pid=player_id, preset=selected.position) + return selected + + +def play_favorite(favorite: str | int) -> Favorite: + """Play a favorite selected by 1-based position or case-insensitive name.""" + favorites = list_favorites() + selected: Favorite | None = None + if isinstance(favorite, int) or str(favorite).strip().isdigit(): + position = int(favorite) + selected = next((item for item in favorites if item.position == position), None) + else: + requested_name = str(favorite).strip().casefold() + selected = next((item for item in favorites if item.name.casefold() == requested_name), None) + if selected is None: + raise HeosError(f"HEOS favorite not found: {favorite!r}") + _command("browse/play_preset", pid=_player_id(), preset=selected.position) + return selected + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default=_host, help=f"HEOS address (default: {_host})") + parser.add_argument("--port", type=int, default=_port, help=f"HEOS CLI port (default: {_port})") + parser.add_argument("--timeout", type=float, default=_timeout) + subparsers = parser.add_subparsers(dest="action", required=True) + subparsers.add_parser("list", help="list available HEOS favorites") + play_parser = subparsers.add_parser("play", help="play a favorite by number or name") + play_parser.add_argument("favorite") + args = parser.parse_args() + if not 1 <= args.port <= 65535: + parser.error("--port must be between 1 and 65535") + if args.timeout <= 0: + parser.error("--timeout must be positive") + configure(args.host, args.port, args.timeout) + try: + if args.action == "list": + for favorite in list_favorites(): + print(f"{favorite.position:2}. {favorite.name}") + else: + selected = play_favorite(args.favorite) + print(f"Playing HEOS favorite {selected.position}: {selected.name}") + except HeosError as error: + print(f"HEOS {args.action} failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/zigbee-events.log b/zigbee-events.log index 5888691..5270115 100644 --- a/zigbee-events.log +++ b/zigbee-events.log @@ -11,3 +11,51 @@ 2026-08-22 14:03:19,925 INFO called device="Radio Orb" event="single_button_1" function=denon.on 2026-08-22 14:10:26,225 INFO Stopping on signal 2 2026-08-22 14:10:26,226 INFO Zigbee event listener stopped +2026-08-22 14:24:28,064 INFO Starting Zigbee event listener +2026-08-22 14:24:28,071 INFO Connected to MQTT broker 192.168.178.247:1883; listening to 1 device(s) +2026-08-22 14:24:28,116 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_1" payload={"action":"single_button_1","battery":100,"linkquality":33,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:24:28,140 INFO called device="Radio Orb" event="single_button_1" function=denon.on +2026-08-22 14:24:50,076 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_2" payload={"action":"single_button_2","battery":100,"linkquality":3,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:24:50,083 INFO called device="Radio Orb" event="single_button_2" function=denon.off +2026-08-22 14:40:02,927 INFO Stopping on signal 2 +2026-08-22 14:40:02,929 INFO Zigbee event listener stopped +2026-08-22 14:41:32,416 INFO Starting Zigbee event listener +2026-08-22 14:41:32,424 INFO Connected to MQTT broker 192.168.178.247:1883; listening to 1 device(s) +2026-08-22 14:41:32,467 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_2" payload={"action":"single_button_2","battery":100,"linkquality":3,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:41:32,506 INFO called device="Radio Orb" event="single_button_2" function=denon.volume_down +2026-08-22 14:41:39,239 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="long_button_2" payload={"action":"long_button_2","battery":100,"linkquality":24,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:41:39,301 INFO called device="Radio Orb" event="long_button_2" function=denon.volume_up +2026-08-22 14:41:41,903 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="long_button_2" payload={"action":"long_button_2","battery":100,"linkquality":33,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:41:41,948 INFO called device="Radio Orb" event="long_button_2" function=denon.volume_up +2026-08-22 14:41:44,462 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="long_button_2" payload={"action":"long_button_2","battery":100,"linkquality":33,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:41:44,542 INFO called device="Radio Orb" event="long_button_2" function=denon.volume_up +2026-08-22 14:49:15,684 INFO Stopping on signal 2 +2026-08-22 14:49:15,685 INFO Zigbee event listener stopped +2026-08-22 14:49:17,774 INFO Starting Zigbee event listener +2026-08-22 14:49:17,779 INFO Connected to MQTT broker 192.168.178.247:1883; listening to 1 device(s) +2026-08-22 14:49:17,824 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="long_button_2" payload={"action":"long_button_2","battery":100,"linkquality":33,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:49:17,876 INFO called device="Radio Orb" event="long_button_2" function=denon.volume_up +2026-08-22 14:49:25,063 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_4" payload={"action":"single_button_4","battery":100,"linkquality":21,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:49:26,189 INFO called device="Radio Orb" event="single_button_4" function=denon.next +2026-08-22 14:55:07,851 INFO Stopping on signal 2 +2026-08-22 14:55:07,852 INFO Zigbee event listener stopped +2026-08-22 14:55:09,373 INFO Starting Zigbee event listener +2026-08-22 14:55:09,379 INFO Connected to MQTT broker 192.168.178.247:1883; listening to 1 device(s) +2026-08-22 14:55:09,423 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_4" payload={"action":"single_button_4","battery":100,"linkquality":21,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:09,528 INFO called device="Radio Orb" event="single_button_4" function=denon.volume_up +2026-08-22 14:55:19,370 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_4" payload={"action":"single_button_4","battery":100,"linkquality":15,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:19,411 INFO called device="Radio Orb" event="single_button_4" function=denon.volume_up +2026-08-22 14:55:20,701 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_4" payload={"action":"single_button_4","battery":100,"linkquality":3,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:20,803 INFO called device="Radio Orb" event="single_button_4" function=denon.volume_up +2026-08-22 14:55:23,159 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_4" payload={"action":"single_button_4","battery":100,"linkquality":0,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:23,198 INFO called device="Radio Orb" event="single_button_4" function=denon.volume_up +2026-08-22 14:55:25,617 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="long_button_3" payload={"action":"long_button_3","battery":100,"linkquality":12,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:25,625 INFO called device="Radio Orb" event="long_button_3" function=denon.volume_mute +2026-08-22 14:55:29,407 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_4" payload={"action":"single_button_4","battery":100,"linkquality":21,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:29,455 INFO called device="Radio Orb" event="single_button_4" function=denon.volume_up +2026-08-22 14:55:33,707 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_3" payload={"action":"single_button_3","battery":100,"linkquality":15,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:33,887 INFO called device="Radio Orb" event="single_button_3" function=denon.volume_down +2026-08-22 14:55:35,857 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_2" payload={"action":"single_button_2","battery":100,"linkquality":27,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:55:37,085 INFO called device="Radio Orb" event="single_button_2" function=denon.previous +2026-08-22 14:58:41,282 INFO Stopping on signal 2 +2026-08-22 14:58:41,283 INFO Zigbee event listener stopped diff --git a/zigbee_events.py b/zigbee_events.py index 58fee27..f7a0f59 100644 --- a/zigbee_events.py +++ b/zigbee_events.py @@ -29,6 +29,7 @@ FUNCTIONS = { "denon.previous": lib_denon.previous, "denon.volume_up": lib_denon.volume_up, "denon.volume_down": lib_denon.volume_down, + "denon.volume_mute": lib_denon.volume_mute, }