now a working radio controller

This commit is contained in:
2026-08-22 14:59:22 +02:00
parent 35969ad305
commit 16a88f8d1f
6 changed files with 334 additions and 7 deletions
+24
View File
@@ -506,6 +506,7 @@ events:
Volume actions are also available as `denon.volume_up` and Volume actions are also available as `denon.volume_up` and
`denon.volume_down`. Each call sends five volume steps to the receiver. `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 The Denon receiver connection is also configured in `events.yaml`. Power
actions use Denon's HTTP control endpoint; set **Settings → Network → Network actions use Denon's HTTP control endpoint; set **Settings → Network → Network
@@ -534,4 +535,27 @@ After changing `events.yaml`, restart the listener:
```bash ```bash
sudo systemctl restart zigbee-events.service 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) - [RRDtool graph elements](https://oss.oetiker.ch/rrdtool/doc/rrdgraph_graph.en.html)
+6 -3
View File
@@ -32,9 +32,12 @@ devices:
enabled: true enabled: true
events: events:
single_button_1: denon.on single_button_1: denon.on
single_button_2: denon.off long_button_1: denon.off
single_button_3: denon.previous single_button_2: denon.previous
single_button_4: denon.next 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 - name: Device 2
topic: zigbee2mqtt/CHANGE_ME_DEVICE_2 topic: zigbee2mqtt/CHANGE_ME_DEVICE_2
+53 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import socket
import ssl import ssl
import sys import sys
from collections.abc import Callable from collections.abc import Callable
@@ -10,6 +11,8 @@ from urllib.error import URLError
from urllib.parse import quote from urllib.parse import quote
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
import lib_heos
_host = "192.168.178.177" _host = "192.168.178.177"
_port = 8080 _port = 8080
@@ -54,12 +57,44 @@ def off() -> None:
_command("PWSTANDBY") _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: 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: 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: def volume_up() -> None:
@@ -74,12 +109,25 @@ def volume_down() -> None:
_command("MVDOWN") _command("MVDOWN")
def volume_mute() -> None:
"""Mute the volume."""
_command("MUON")
def main() -> int: def main() -> int:
"""Run a receiver action from the command line.""" """Run a receiver action from the command line."""
parser = argparse.ArgumentParser(description="Control the Denon receiver") parser = argparse.ArgumentParser(description="Control the Denon receiver")
parser.add_argument( parser.add_argument(
"action", "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("--host", default=_host, help=f"receiver address (default: {_host})")
parser.add_argument("--port", type=int, default=_port, help=f"HTTP port (default: {_port})") parser.add_argument("--port", type=int, default=_port, help=f"HTTP port (default: {_port})")
@@ -116,10 +164,11 @@ def main() -> int:
"previous": previous, "previous": previous,
"volume-up": volume_up, "volume-up": volume_up,
"volume-down": volume_down, "volume-down": volume_down,
"volume-mute": volume_mute,
} }
try: try:
actions[args.action]() 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) print(f"Denon {args.action} failed: {error}", file=sys.stderr)
return 1 return 1
return 0 return 0
+202
View File
@@ -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())
+48
View File
@@ -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: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,225 INFO Stopping on signal 2
2026-08-22 14:10:26,226 INFO Zigbee event listener stopped 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
+1
View File
@@ -29,6 +29,7 @@ FUNCTIONS = {
"denon.previous": lib_denon.previous, "denon.previous": lib_denon.previous,
"denon.volume_up": lib_denon.volume_up, "denon.volume_up": lib_denon.volume_up,
"denon.volume_down": lib_denon.volume_down, "denon.volume_down": lib_denon.volume_down,
"denon.volume_mute": lib_denon.volume_mute,
} }