179 lines
5.3 KiB
Python
179 lines
5.3 KiB
Python
"""Small HTTP client for controlling a Denon receiver."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
from collections.abc import Callable
|
|
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
|
|
_timeout = 3.0
|
|
_verify_tls = True
|
|
|
|
|
|
def configure(
|
|
host: str,
|
|
port: int = 8080,
|
|
timeout: float = 3.0,
|
|
verify_tls: bool = True,
|
|
) -> None:
|
|
"""Configure the receiver used by the parameterless action functions."""
|
|
global _host, _port, _timeout, _verify_tls
|
|
_host = host
|
|
_port = port
|
|
_timeout = timeout
|
|
_verify_tls = verify_tls
|
|
|
|
|
|
def _command(command: str) -> None:
|
|
"""Send one command through Denon's HTTP control endpoint."""
|
|
encoded_command = quote(command, safe="")
|
|
url = f"http://{_host}:{_port}/goform/formiPhoneAppDirect.xml?{encoded_command}"
|
|
request = Request(url, headers={"User-Agent": "service-zigbee/1.0"})
|
|
context = None if _verify_tls else ssl._create_unverified_context()
|
|
with urlopen(request, timeout=_timeout, context=context) as response:
|
|
# Reading the response completes the request and lets urllib reuse/close
|
|
# the connection cleanly. HTTP error responses raise an exception.
|
|
response.read()
|
|
|
|
|
|
def on() -> None:
|
|
"""Turn the receiver on."""
|
|
_command("PWON")
|
|
print("On")
|
|
|
|
|
|
def off() -> None:
|
|
"""Put the receiver into standby."""
|
|
_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:
|
|
"""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:
|
|
"""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:
|
|
"""Increase the volume by 5 points."""
|
|
for _ in range(5):
|
|
_command("MVUP")
|
|
|
|
|
|
def volume_down() -> None:
|
|
"""Decrease the volume by 5 points."""
|
|
for _ in range(5):
|
|
_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",
|
|
"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})")
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=_timeout,
|
|
help=f"request timeout in seconds (default: {_timeout:g})",
|
|
)
|
|
tls_group = parser.add_mutually_exclusive_group()
|
|
tls_group.add_argument(
|
|
"--verify-tls",
|
|
action="store_true",
|
|
default=_verify_tls,
|
|
help="verify the receiver's HTTPS certificate",
|
|
)
|
|
tls_group.add_argument(
|
|
"--no-verify-tls",
|
|
action="store_false",
|
|
dest="verify_tls",
|
|
help="accept a self-signed HTTPS certificate",
|
|
)
|
|
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, args.verify_tls)
|
|
actions: dict[str, Callable[[], None]] = {
|
|
"on": on,
|
|
"off": off,
|
|
"next": next,
|
|
"previous": previous,
|
|
"volume-up": volume_up,
|
|
"volume-down": volume_down,
|
|
"volume-mute": volume_mute,
|
|
}
|
|
try:
|
|
actions[args.action]()
|
|
except (OSError, URLError, RuntimeError, lib_heos.HeosError) as error:
|
|
print(f"Denon {args.action} failed: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|