Recording from HDMI
This article is a case study of the adoption of HDMI capture devices for the purpose of recording video and audio using MythTV. It also touches on infrared channel changing and the use of system events.
Context
For many years I have been recording analogue video and audio from set-top boxes using Hauppauge HVR-1900 capture devices (served in Linux by the pvrusb2 module). Whereas this worked OK there were several disadvantages, including the fact that only SD video can be captured this way, which is also letterboxed.
So I wanted to move to a fully-digital capture pipeline, at reasonable cost.
I already had access to a number of unencrypted DVB-C channels, which I was able to record using an HDHR, but the remaining channels can only be accessed through set-top boxes provided by the cable company. Their only digital output is HDMI, so the problem is focused on capturing audio and video from HDMI.
Video and audio capture pipeline
Each of the pipelines consists of a set-top box, an HDMI capture device and an IR blaster for controlling the set-top box.
HDMI capture device
Based on the information on this page and the further resources it links to, I decided to purchase LKV373A senders. These devices are intended to transport HDMI audio/video over gigabit ethernet, and so they are normally sold as sets of a sender and a receiver. However, for our purposes only the sender is needed, and it is possible to purchase only the senders from some online sellers.
Note that these devices can only be used for recording if the HDMI signal is HDCP-free. The topic of dealing with HDCP if present is not discussed and please don’t ask about it here.
These devices capture HDMI video and audio, and output it as an H.264 stream. Audio is limited to 2-channel MP2 at 192 kbit/s.
Several hardware revisions of this device exist; what is needed is V3 hardware, and the firmware recommended on the above page. Don’t buy unless you are sure that you’ll be getting V3 hardware.
I assigned each capture device a fixed IP address in my DHCP servers and gave them each a DNS hostname.The HDMI capture device needs to be configured to send its H.264 stream to the IP address of the MythTV backend server. This is done by means of an HTTP GET command as described on Juul’s page above. In my environment the URL that causes the capture device to start streaming is as follows:
http://192.168.1.69/dev/info.cgi?action=streaminfo&rtp=on&multicast=on&mcastaddr=192.168.1.60
In this URL, 192.168.1.69
is the IP address of the capture device, while 192.168.1.60
is the IP address of the MythTV backend server.
Unfortunately, the stream is always sent to UDP port 5004, so that if you have more than one HDMI capture device in use at the same time, a UDP socket listening to port 5004 on the backend server would receive intermingled data from all capture devices, which is useless. So either we need to be able to instruct the application that receives the streams to create separate sockets based not only on the destination port number but also on the source address, or we need to separate the streams onto different destination UDP ports. I went for the latter solution.
Specifically, I created nftables
rules that redirect the video streams from port 5004 to ports 17001, 17002 and 17003 based on whether the stream comes from the 1st, 2nd or 3rd HDMI capture device.
#!/usr/sbin/nft -f flush ruleset table inet filter { chain input { type filter hook input priority 0; } chain forward { type filter hook forward priority 0; } chain output { type filter hook output priority 0; } } table inet nat { chain prerouting { type nat hook prerouting priority dstnat; iif enp3s0 ip saddr { 192.168.1.69 } udp dport 5004 dnat 192.168.1.60:17001; iif enp3s0 ip saddr { 192.168.1.74 } udp dport 5004 dnat 192.168.1.60:17002; iif enp3s0 ip saddr { 192.168.1.75 } udp dport 5004 dnat 192.168.1.60:17003; } }
In the above file, 192.168.1.{69,74,75}
are the IP addresses of the HDMI capture devices, while 192.168.1.60
is the IP address of the MythTV backend, and enp3s0
is the name of the Ethernet interface on which the streams arrive on the backend server.
On the backend server, the video/audio stream can be captured using ffmpeg
as follows. The example is for the 1st HDMI capture device; substitute the port number for the other devices as needed:
$ /usr/bin/ffmpeg -hide_banner -nostats -loglevel panic -i udp://192.168.1.60:17001 -vcodec copy -filter_complex "asetpts=PTS-0.3/TB" -f mpegts pipe:1
Some explanation on this incantation:
-
-hide_banner
,-nostats
and-loglevel panic
are needed in order to prevent ffmpeg outputting anything other than video/audio, sincemythexternrecorder
expects this command to output the stream on its standard output (stdout). -
-i udp://192.168.1.60:17001
tells ffmpeg from where to acquire the stream. -
-vcodec copy
tells ffmpeg to apply thecopy
codec to the video content of the stream, meaning that video is transparently copied from the input to the output stream without any processing. -
-filter_complex "asetpts=PTS-0.3/TB"
improves lipsync by telling ffmpeg to subtract 300ms from the presentation time stamps of audio packets. -
-f mpegts
tells ffmpeg to encapsulate the stream as an MPEG transport stream. -
pipe:1
tells ffmpeg to output the stream on stdout.
ffmpeg
is used to receive the stream because minor tweaks are needed for it to be usable by MythTV. These tweaks do not take a lot of CPU so they can be done safely in real time. Another option would have been to record the raw stream and have MythTV transcode it later. However, such an approach would prevent live TV from working.
IR blaster
In my previous setup I had only been using two set-top boxes and had been controlling them using LIRC with two IR blaster LEDs connected to an MCE USB IR receiver/sender. However, I wanted to adopt a different IR blaster methodology for the following reasons:
- Firstly, the LIRC software project carries a lot of technical debt, which results in LIRC being quite difficult to configure and use.
- Secondly, I wanted to move to a setup involving 3 capture pipelines, meaning that I have 3 STBs to control rather than 2, so that in any event I needed additional IR blaster hardware.
Whereas many other options exist, I decided to adopt Arduino Nano hardware with GirsLite firmware, as described on this page. The Arduino Nanos I use only have the blaster LEDs equipped (called IR sending diodes on the above page); the visible-light LEDs and IR receivers have not been equipped because they are not needed.
I placed each Arduino Nano in a small plastic enclosure and drilled a small hole such that I can precisely point the blaster at the STB it is meant to control without interfering with the other STBs.
I created udev
rules in order to ensure that each Arduino Nano is reliably assigned a persistent name upon each boot of the backend server.
I used IrScrutinizer
(a Java program described and available here) and a fully-equipped Arduino Nano to capture the IR signals emitted by the STB remote. My STBs use the TDC-38 protocol, and, for example, the following command line sends digit 1 to the STB:
$ harchardware --arduino --device /dev/arduino1 --transmit --protocol TDC-38 --names D=18,F=2,S=10
The harchardware
tool is included in the IrScrutinizer
package.
In order to send multiple signals in one harchardware
invocation, use the --file
option as follows:
Create a file containing the signals to be sent, e.g.
$ cat arguments.txt --protocol TDC-38 --names D=18,F=2,S=10 --protocol TDC-38 --names D=18,F=5,S=10 --protocol TDC-38 --names D=18,F=2,S=10
Then send them all in one go as follows:
$ harchardware --arduino --device /dev/arduino1 transmit --file arguments.txt
This is needed because harchardware
is also a Java application, and upon each invocation the JVM has to start which (depending on your server hardware) can take several seconds, which is too long given the inter-digit timeout STBs typically have when receiving a multi-digit channel number. This problem occurs even on my backend which sports a 4GHz Intel CPU, fast RAM and SSD.
Note that the --file
option to harchardware
is, as of this writing, available only in snapshot releases; it will be included in a future general release.
Alternative method
Having said all that, I am actually using a different method for issuing blaster commands to the Arduinos, not involving harchardware
. The reason is that I had implemented this method before Bengt Martensson, the author of IrScrutinizer
and harchardware
, had developed the --file
option.
My method is based on using the GirsLite firmware directly using an expect
script. I wrote a bash
script that generates an expect
script such as the following:
#!/usr/bin/expect -f set portID [open /dev/arduino1 r+] set baud 115200 fconfigure $portID -mode "115200,n,8,1" fconfigure $portID -blocking 0 -buffering none spawn -open $portID set timeout 2 send -- "\r" expect "OK\r\n" # 1 send -- "send 1 38000 0 28 0 315 315 315 630 315 315 630 630 315 315 630 630 630 630 315 315 315 315 315 315 315 315 315 315 630 630 315 89000\r" expect "OK\r\n" # 2 send -- "send 1 38000 0 26 0 315 315 315 630 315 315 630 630 315 315 630 630 630 630 315 315 315 315 315 315 315 315 630 630 630 89315\r" expect "OK\r\n" # 1 send -- "send 1 38000 0 28 0 315 315 315 630 315 315 630 630 315 315 630 630 630 630 315 315 315 315 315 315 315 315 315 315 630 630 315 89000\r" expect "OK\r\n"
That script is then executed as follows:
$ /usr/bin/expect -f filename
This is very fast (no JVM startup delay). The send
commands were obtained from IrScrutinizer
’s logfile.
One additional complication is that the STBs didn’t reliably receive two of the same infrared signals one after the other, such as repeating digits in channel numbers. For this reason I modified the bash
script to insert delays between identical signals in the expect
script.
I didn't know this at the time, but with hindsight only my alternative method is able to deal with the repeating digit problem: the --file
option to harchardware
does not support the insertion of delays.
Saving power
With a view to enabling power savings, I have connected the power supply of each pipeline to a USB-controlled power strip, which enables me to switch each outlet on and off individually.
I went for this radical approach for the following reasons:
- Among the various devices that make up a pipeline, only the STB natively supports being switched on and off, so simply using infrared to turn the STB on and off would still leave the other devices running and consuming power 24/7.
- The STB can be configured to either go into standby when turned off by means of the infrared signal, or to go into a deep sleep mode. Only in the latter mode does the STB actually conserve a meaningful amount of power, because in standby it continues to be synchronised to the cable signal. But waking from deep sleep takes around 90 seconds, which is very similar to the amount of time it takes the STB to boot from power-on. There is therefore very little reason not to switch the STB off completely when not in use.
The challenge is to power the pipeline up before recordings start, making sure that we don’t try and tune to the desired channel until sufficient time has passed for the STB to fully boot to a state in which it can receive and act on infrared signals.
A simple approach would be to power the pipeline up from the REC_PENDING system event, and be done with it. This event is triggered repeatedly before the beginning of scheduled recordings: at 120s, 90s, 60s, and 30s before the recording is due to start. So if we power the pipeline up 120s before the recording starts, the channel change command that is triggered by the beginning of the recording will be successful.
However, we can’t be sure that the REC_PENDING event will indeed be triggered that long ahead of time:
- At the time of writing this, there is a MythTV bug that causes the REC_PENDING event only to begin being triggered 60s before the recording starts, rather than 120s. That bug was resolved in fixes/33 and master before this article was published.
- If a recording rule is created late then that is another reason why the 120s notice period would not be able to be respected.
- Live TV is essentially a recording without any notice at all.
So what we need to do is tell the channel changing script how long ago the pipeline was powered on, so that it knows whether and how long it needs to wait before sending infrared signals to the STB. This means that channel changes may be delayed by a few (tens of) seconds from the beginning of recordings, but that is better than the channel change not happening at all. This period of uncertainty needs to be mitigated by having recording rules start the recording sufficiently early relative to the start time provided by the guide data.
Integration scripts
blast_arduino.sh
This script sends IR signals to the set-top box. The signals can be a channel number (specified as either a multi-digit number such as 121 or a series of single digits such as 1 2 1) or any other signal name for which an entry exists in the sendcmds.txt
file. Once again: that file was constructed based on IrScrutinizer
log output.
The script generates an expect
script and then executes that.
#!/bin/bash # Optional: --dry-run # $1: transmitter # $2 and following: channel number (1 or more digits) dryrun=0 logger --id=$PPID -p local7.info "$0 called with parameters $* by PID $PPID" -s 2>> /home/mythtv/hdmicap.log if [ "$1" == "--dry-run" ] ; then dryrun=1 shift fi case $1 in 1 | hdmicap1 | hdmicap1.example.com) export device=/dev/arduino1 export outfile=/tmp/expect1 export switchno=1 ;; 2 | hdmicap2 | hdmicap2.example.com) export device=/dev/arduino2 export outfile=/tmp/expect2 export switchno=2 ;; 3 | hdmicap3 | hdmicap3.example.com) export device=/dev/arduino3 export outfile=/tmp/expect3 export switchno=3 ;; *) exit 1 ;; esac cat << EOF > ${outfile} #!/usr/bin/expect -f set portID [open ${device} r+] set baud 115200 fconfigure \$portID -mode "115200,n,8,1" fconfigure \$portID -blocking 0 -buffering none spawn -open \$portID set timeout 2 send -- "\r" expect "OK\r\n" EOF shift prevcmd=none while (( "$#" )); do if [ -n "$1" ] && [ "$1" -eq "$1" ] 2>/dev/null; then # this is a number numstr=$1 for (( i=0; i<${#numstr}; i++ )); do cmd=`fgrep ${numstr:$i:1}: /home/mythtv/sendcmds.txt | awk '{$1=""; print $0}'` if [ "$cmd" == "$prevcmd" ]; then echo sleep 1 >> ${outfile} fi echo \# ${numstr:$i:1} >> ${outfile} echo send -- \"send 1 38000 ${cmd}\\r\" >> ${outfile} echo expect \"OK\\r\\n\" >> ${outfile} prevcmd=$cmd done else # this is not a number cmd=`fgrep $1: /home/mythtv/sendcmds.txt | awk '{$1=""; print $0}'` if [ "$cmd" == "$prevcmd" ]; then echo sleep 1 >> ${outfile} fi echo \# $1 >> ${outfile} echo send -- \"send 1 38000 ${cmd}\\r\" >> ${outfile} echo expect \"OK\\r\\n\" >> ${outfile} prevcmd=$cmd fi shift done if [ "$dryrun" == "0" ] ; then ### Make sure that the STB is fully ready before changing channels /home/mythtv/hcpow.sh $switchno wait /usr/bin/expect -f ${outfile} fi rm ${outfile} exit 0
start_streaming
This helper script instructs the HDMI capture device to start streaming to the MythTV backend.
#!/bin/bash myIPaddr=$(hostname -I) sourceIPaddr="$(host -t A ${1} | awk '/has.*address/{print $NF; exit}')" if [ "`/bin/ping -c 1 $sourceIPaddr`" ] ; then URL="http://$sourceIPaddr/dev/info.cgi?action=streaminfo&rtp=on&multicast=on&mcastaddr=$myIPaddr" curl --silent --output /dev/null $URL else exit 1 fi exit 0
stop_streaming
I haven’t yet found a good method for telling the device to stop streaming. For now this script merely resets the HDMI capture device. However, since the pipeline is also being switched off at the end of recordings it doesn't really matter.
#!/bin/bash if [ "`/bin/ping -c1 $1`" ]; then nc $1 9999 -i 1 <<EOS reboot EOS else #### Deal with a crashed HDMI capture device ### Code omitted from this example fi exit 0
have_video_lock
This script asks the HDMI capture device whether an HDMI signal is available on its input.
#!/bin/bash if [ "`/bin/ping -c 1 $1`" ] ; then nc $1 9999 -i 1 > /tmp/have_video_lock_$1.out <<EOS get_video_lock exit EOS result=$(cat /tmp/have_video_lock_$1.out | head -n 5 | tail -n 1) result=$(echo $result|tr -d '"\r\n') case $result in "Lock") nc $1 9999 -i 1 > /tmp/have_video_lock_$1.out <<EOS get_hdcp exit EOS result=$(cat /tmp/have_video_lock_$1.out | head -n 5 | tail -n 1) result=$(echo $result|tr -d '"\r\n') case $result in "Off") retval=0 ;; *) retval=2 ;; esac ;; "Unlock") retval=1 ;; *) retval=2 ;; esac else retval=3 fi exit $retval
tuner_command
This script is invoked by the MythTV external recorder as specified in the |mythexternrecorder configuration file. It performs some sanity checking, then tells the STB to start streaming and tunes the STB to the correct channel.
#!/bin/bash # # $1: FQDN of the streaming device # $2: channel number # https://stegard.net/2022/05/locking-critical-sections-in-shell-scripts/ lock_acquire() { # Open a file descriptor to lock file exec {LOCKFD}>$lockfile || return 1 # Block until an exclusive lock can be obtained on the file descriptor flock -x $LOCKFD } lock_release() { test "$LOCKFD" || return 1 # Close lock file descriptor, thereby releasing exclusive lock exec {LOCKFD}>&- && unset LOCKFD } case $1 in hdmicap1.example.com | hdmicap1) export switchno=1 ;; hdmicap2.example.com | hdmicap2) export switchno=2 ;; hdmicap3.example.com | hdmicap3) export switchno=3 ;; *) exit 1 esac export lockfile=/tmp/tuner_command$switchno lock_acquire || { echo "$0[$$]: Failed to acquire lock (held by $(cat $lockfile))" ; exit 0; } echo $$ > $lockfile # BEGIN CRITICAL SECTION /home/mythtv/have_video_lock $1 if [ $? -ne 0 ]; then elapsed=0 until /home/mythtv/have_video_lock $1; do if [ $elapsed -ge 20 ]; then ## Power-cycling code omitted elif [ $elapsed -ge 60 ]; then lock_release exit 1 fi elapsed=$(($elapsed+2)) sleep 2s done fi ### Start streaming /home/mythtv/start_streaming $1 ### Tune the STB to the required channel /home/mythtv/blast_arduino.sh $switchno $2 # END CRITICAL SECTION lock_release exit 0
cleanup_command
This script is invoked by mythexternrecorder
.
#!/bin/bash logger --id=$PPID -p local7.info "$0 called with parameters $* by PID $PPID" -s 2>> /home/mythtv/hdmicap.log /home/mythtv/stop_streaming $1 # Also switch the STB off case $1 in hdmicap1.example.com | hdmicap1) export switchno=1 ;; hdmicap2.example.com | hdmicap2) export switchno=2 ;; hdmicap3.example.com | hdmicap3) export switchno=3 ;; *) exit 1 esac /home/mythtv/hcpow.sh $switchno off
system-events/recording-pending
This script is an event handler for the MythTV recording pending system event with %CARDID%
as its sole parameter.
I have configured each pipeline with two virtual tuners, and the first instances of the three pipelines have CARDID values 32-34, and their second instances have CARDID values 35-37.
system-events/recording-pending
#!/bin/bash logger --id=$PPID -p local7.info "$0 called with parameters $* by PID $PPID" -s 2>> /home/mythtv/hdmicap.log devid=$1 case $devid in 32|33|34) /home/mythtv/hcpow.sh $(expr $devid - 31) on ;; 35|36|37) /home/mythtv/hcpow.sh $(expr $devid - 34) on ;; esac
hcpow.sh
This script is a finite state machine that governs the powering of the pipelines.
The program that actually turns the USB-controlled power strip on and off is sispmctl
.
#!/bin/bash # # Usage: hcpow.sh <pipeline> <command> # # <pipeline> is a number (1-3) # # <command> is one of: # status: emits the power state of the given pipeline on stdout # on: power the given pipeline on # off: power the given pipeline off # wait: wait for the given pipeline to transition from the poweringup # state to the on state. # MAGIC: intended only for recursive use by hcpow itself. Requires # an additional parameter: PID of the caller # # Each pipeline is in one of the following states: # # off: The pipeline is powered down, and there is no state file. # # poweringup: The pipeline has been instructed to power up but isn't # fully ready yet. There is a state file having a name of the form # hcpow.<pipeline>.poweringup.<PID> # # on: The pipeline is powered up and fully operational. There is a # state file having a name of the form hcpow.<pipeline>.on # # Returns a nonzero value in case of errors. Error messages are emitted on # sterr. There is normally no output on stdout, except in response to the # status command. stateroot=/tmp/hcpow pipeline=$1 cmd=$2 case ${cmd} in status) if [ -f $stateroot.$pipeline.poweringup.* ] ; then echo poweringup elif [ -f $stateroot.$pipeline.on ]; then echo on else echo off fi ;; on) if [ -f $stateroot.$pipeline.* ] ; then # Do nothing: the pipeline is either already on or is powering up : else touch $stateroot.$pipeline.poweringup.$$ sispmctl -o $pipeline # Can't schedule the rename using at because it doesn't support # timespecs expressed in seconds. So just sleep in the background. $0 $pipeline MAGIC $$ & fi ;; MAGIC) # Called recursively, and as a background task, by the on command sleep 90s # Note that this use of mv is robust in case the pipeline gets powered # down again during the 90s period, because if that happens the state # file will have been deleted such that the mv is a no-op. # Furthermore if the pipeline also gets powered on again during that # period, then the PID of the script handling the on command will be # different from the one the MAGIC command was invoked by, so the # mv is matched to the correct invocation. mv $stateroot.$pipeline.poweringup.$3 $stateroot.$pipeline.on ;; off) rm $stateroot.$pipeline.* sispmctl -f $pipeline ;; wait) hadtowait=0 if [ -f $stateroot.$pipeline.poweringup.* ]; then logger --id=$PPID -p local7.info "$0 $*: waiting for pipeline $pipeline to boot" -s 2>> /home/mythtv/hdmicap.log hadtowait=1 fi while [ ! -f $stateroot.$pipeline.on ] ; do # Detect whether the pipeline has been powered down (state file has # disappeared). Exit if so. if [ ! -f $stateroot.$pipeline.* ] ; then break fi sleep 1s done if [ "$hadtowait" == "1" ] ; then logger --id=$PPID -p local7.info "$0 $*: done waiting for pipeline $pipeline" -s 2>> /home/mythtv/hdmicap.log fi ;; *) echo $0: unknown command $cmd >&2 exit 1 ;; esac exit 0
MythTV configuration
MythTV’s mythexternrecorder
is used as the vehicle for ingesting the captured streams into MythTV. It is briefly discussed on the ExternalRecorder page, where it is referred to as the Generic External Recorder.
mythexternrecorder configuration file
Each capture pipeline requires a separate instance of mythexternrecorder
, and therefore a separate configuration file. The one shown here is for the first pipeline; the differences relative to the other pipelines are trivial.
[RECORDER] command="/usr/bin/ffmpeg -hide_banner -nostats -loglevel panic -i udp://192.168.1.60:17001 -vcodec copy -filter_complex "asetpts=PTS-0.3/TB" -f mpegts pipe:1" cleanup="/bin/bash /home/mythtv/cleanup_command hdmicap1.local" desc="hdmicap1 %CHANNUM% %CHANNAME% %CALLSIGN%" [TUNER] channels=/home/mythtv/hdmicap_channels.conf command="/bin/bash /home/mythtv/tuner_command hdmicap1.local %CHANNUM%" newepisodecommand="/bin/bash /home/mythtv/blast_arduino.sh 1 %CHANNUM%" timeout=80000
mythtv-setup
In mythtv-setup
, each capture pipeline has a Capture Card entry as follows:
Card Type = External (black box) Recorder ->
Command Path = /usr/bin/mythexternrecorder --conf /home/mythtv/mythexternrecorder1.conf
Tuning timeout = 120000
Each capture card is also connected to a Video Source as follows:
Input Name = MPEG2TS
Display Name = <pick something you like, but the last two characters have to be globally unique. I went for HDMI1, HDMI2 and HDMI3>
Video Source = select an appropriate video source
External Channel Change Command = <blank>