Sven and the Art of Computer Maintenance

Sven and the Art of Computer Maintenance

10 Jan 2021

Extending the functionality of a Denon AV receiver

Denon AVR-X2700H

Introduction

Recently I designed and implemented a full home cinema. It included a TV, media playing devices, a large number of speakers (front, rear, ceiling, subwoofer) and an audio/video receiver (AVR) to drive the speakers. Everything is controlled by a single Logitech Harmony infrared remote control (something with which I have had some experience with in the past).

Everything worked great, except for one part. The used subwoofer was part of a Logitech Z-5500 speaker set. It is used with an adapter that makes it work with any receiver that has a subwoofer pre-out output, instead of the stock control pod. It has its own powered amplifier, but it can only be turned on and off by (un)plugging the power or by switching a small power switch on the back of the device. When it is turned on, it consumes around 40W of power. Keeping the device on when it is not used is unacceptable due to the high energy cost, and turning the device on and off manually is uncomfortable.

A solution would be to let the AVR switch the power of the subwoofer. The AVR concerns a Denon AVR-X2700H, of which the European model only has an Ethernet connection for intelligent input/output with other devices. This model also lacks switched power outlets, something which older receivers used to have in times long gone.

Luckily, this particular receiver supports the Denon AVR control protocol over Ethernet. This protocol can be used to both read the status of the receiver (push and pull) and to actively manage it. It concerns a simple, proprietary protocol of which the specifications can be downloaded.

I had some old unused components lying around, which I repurposed to come to a solution. It concerns a Raspberry Pi 1 model B+ and an ancient Gembird SIS-PM USB programmable power outlet strip. The Raspberry Pi model was quite appropriate since I needed something with Ethernet (to communicate with the AVR) and USB (to communicate with the power outlet strip) that would draw the least amount of power. The 1 model B+ typically uses 330 mA and when idle even less (often 200 mA). Its power use can be reduced further by disabling its unused HDMI video output. Without any tasks, the Pi would then consume around 1 watt of power. Since the Pi will be idle most of the time anyway, this is a far more efficient solution compared to simply having the subwoofer on all the time.

Overview

An overview of the connections between components

See the figure for a connection overview of all components. Note that in my configuration, the AVR and Raspberry Pi are directly connected with each other and not to another network. This is due to that functionally, it is not required to have a connection to any other devices (local or on the Internet) if no other network-based services are used. Furthermore, the Denon AVR control protocol is insecure. There is no access control nor authentication, and all communication is in cleartext. If there is no functional requirement that requires the receiver to be connected to a network, it is better not to do so to improve security.

Both the AVR and Raspberry Pi need configuration.

Configure the AVR

For optimal operation, open the AVR’s Setup menu and go to Network -> Network Control. Set it to ‘Always On’. This ensures that the Raspberry Pi can make a connection to the AVR and keep the connection open at all times, even when the AVR is powered off.

If you are going to connect the AVR directly to the Raspberry Pi using Ethernet, it is necessary to configure a static IP address. Do this in the Setup menu. Go to Network -> Settings. The IP address I use for the AVR is 192.168.0.2, with a subnet mask of 255.255.255.0.

Configure the Raspberry Pi

For this tutorial it is assumed that Arch Linux ARM is installed on the Pi. Other Linux distributions should work as well with some minor adjustments to some commands.

Install required packages

Some additional software is required. It is easily installed by running:

sudo pacman -S --needed --noconfirm base-devel python

Build and install sispmctl

Python has functionality included to work with network connections, making communication with the AVR a programming exercise. However, additional software is required to control the Gembird SIS-PM. I initially thought about using pysispm, a Python library which allows exactly this. Unfortunately, there is no pysispm package available in Arch Linux ARM or in the Arch User Repository (AUR). I could build and install it myself, but where is the fun in that?

What I did find in the AUR was a package for sispmctl, a command line Linux application which supports exactly what I need (reading the status of and controlling the outlets of a connected SIS-PM).

To easily manage packages from AUR I chose to install pikaur. To do so, run:

curl -JLO https://aur.archlinux.org/cgit/aur.git/snapshot/pikaur.tar.gz
tar xf pikaur.tar.gz
cd pikaur
makepkg -sri

Installing sispmctl is now as easy as:

pikaur -S sispmctl

Configure the Python script

A small script is used to maintain synchronization of the power status of the receiver and the subwoofer. It can be written and made executable by running as root:

cat << 'EOF' > /usr/local/bin/avrclient
#!/usr/bin/env python
# Sets SIS-PM power outlet state based on Denon AVR power state

import os
import socket
import asyncio

HOST = '192.168.0.2' # The AVR's hostname or IP address
PORT = 23            # TCP port which offers Denon AVR control protocol

OUTLET = 1           # SIS-PM outlet number to switch

# Connection initialization string. Can probably be anything. If missing, there
# is a possibility that the AVR will not send data.
INIT_DATA = 'abc'

SISPMCTL = '/usr/bin/sispmctl'

DEBUG = False

# Returns the current state of a SIS-PM outlet.
def sispmOutletState(outlet):
  stream = os.popen(f'{SISPMCTL} -q -n -g{outlet}')
  output = int(stream.read())
  return output==1

# Turns a SIS-PM outlet on or off. Will not do anything if the requested
# state equals the current state of the outlet.
def sispmChangeOutlet(outlet, futureState):
  currentState=sispmOutletState(outlet)
  if futureState and not currentState:
    os.popen(f'{SISPMCTL} -o{outlet}')
  elif not futureState and currentState:
    os.popen(f'{SISPMCTL} -f{outlet}')

# Returns the value of a requesting Denon AVR control protocol command.
async def avrGetValue(reader, writer, command):
  command = f'{command}?'
  command = command.encode()
  writer.write(command)
  if DEBUG: print(f'> {command}')
  await writer.drain()
  data = await reader.readuntil(b'\r')
  data = data.decode()
  data = data.rstrip()
  if DEBUG: print(f'< {data}')  
  value = data[2:]
  return value

# Requests the current power state of a Denon AVR, and adjusts the power state
# of the configured SIS-PM outlet accordingly. Meant to be called once upon
# initialization, to ensure that both AVR and outlet share the same power
# state.
async def sync(reader, writer):
  avrState = await avrGetValue(reader, writer, 'PW')
  if avrState=="ON":
    avrState=True
  else:
    avrState=False

  sispmChangeOutlet(OUTLET, avrState)

# Handles power state changes of a Denon AVR based on its control protocol
# reported value for the command 'PW' (power). Meant to be called whenever
# the Denon AVR reports that the power state changed.
async def avrPowerStateChange(powerState):
  if powerState=="ON":
    if DEBUG: print('*** AVR is powered on (this message can appear twice)')
    sispmChangeOutlet(OUTLET, True)

  if powerState=="STANDBY":
    if DEBUG: print('*** AVR went into standby')
    sispmChangeOutlet(OUTLET, False)

# Connects to a Denon AVR using the Denon AVR control protocol, requests the
# initial state of the receiver, and listens for further messages the receiver
# reports.
async def avrMessageListener():
  reader, writer = await asyncio.open_connection(HOST, PORT)
  writer.write(INIT_DATA.encode())
  await writer.drain()

  await sync(reader, writer)

  while True:
    data = await reader.readuntil(b'\r')

    # Make the message cleartext for further processing, and remove any
    # trailing whitespace for implicit EOL conversion
    data = data.decode()
    data = data.rstrip()

    if DEBUG: print(f'< {data}')

    if data[0:2]=="PW": await avrPowerStateChange(data[2:])

asyncio.run(avrMessageListener())
EOF

chmod +x /usr/local/bin/avrclient

Make sure to reconfigure HOST, PORT and OUTLET if necessary.

Configure a static IP address

If the Raspberry Pi is directly connected to the AVR, it is required to configure a static IP address. How to do this exactly is out of the scope of this guide, since it can be done in many different ways. The easiest method is to reconfigure systemd-networkd. You can read more about how to do this here. I gave the Raspberry Pi a static IP address of 192.168.0.1 and a subnet mask of 255.255.255.0.

Start the Python script now and at boot

A systemd service can be added, enabled, and started with (as root):

cat << 'EOF' > /etc/systemd/system/avrclient.service
[Unit]
Description=Denon AVR power state synchronizer
After=network.target

[Service]
Type=simple
Restart=always
RestartSec=60
ExecStart=/usr/local/bin/avrclient

[Install]
WantedBy=multi-user.target
EOF

systemctl enable --now avrclient.service

If the Python script fails (such as when a connection to the AVR might not be possible for whatever reason), systemd will restart it after a minute.

Test the configuration

After the script is started and if the Pi is connected to the AVR and the power outlet strip, a power outlet on the strip should switch based on the power state of the AVR. Use the POWER button on the AVR’s remote control or the power button on its front panel to test this.

Optional: disable HDMI output

Disabling the HDMI output on the Raspberry Pi when it is not needed saves a bit of power. To do this at boot and immediately, run (preferably over SSH):

cat << 'EOF' > /etc/systemd/system/disable-hdmi.service
[Unit]
Description=Disable Raspberry Pi HDMI output
After=network.target

[Service]
Type=oneshot
ExecStart=/opt/vc/bin/tvservice --off

[Install]
WantedBy=multi-user.target
EOF

systemctl enable --now disable-hdmi.service