diff --git a/AUTHORS b/AUTHORS index 82cd221f..cfa052bb 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,7 +29,7 @@ Fred Gleason Patrick Linstruth General Bugfixes rddbconfig(8) Utility - TuneIn and IceCast2 PyPAD scripts + TuneIn, IceCast2, and X-Command PyPAD scripts Dan Mills General Bughunter Extrordinaire diff --git a/ChangeLog b/ChangeLog index a4e328dd..42e4b67e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -18410,3 +18410,6 @@ * Fixed a path bug in the Console Helper configuration that broke startup of rdalsaconfig(8) and rddbconfig(8) from the desktop menu when using default --prefix= value. +2019-01-19 Patrick Linstruth + * Added a 'pypad_xcmd.py' PyPAD script for sending PAD data to + RDS encoders supporting the Pira.cz X-Command protocol. diff --git a/apis/pypad/scripts/Makefile.am b/apis/pypad/scripts/Makefile.am index 9ff6d4cd..c127c9bc 100644 --- a/apis/pypad/scripts/Makefile.am +++ b/apis/pypad/scripts/Makefile.am @@ -50,6 +50,8 @@ install-exec-am: cp pypad_urlwrite.exemplar $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_urlwrite.exemplar ../../../helpers/install_python.sh pypad_walltime.py $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_walltime.py cp pypad_walltime.exemplar $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_walltime.exemplar + ../../../helpers/install_python.sh pypad_xcmd.py $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xcmd.py + cp pypad_xcmd.exemplar $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xcmd.exemplar ../../../helpers/install_python.sh pypad_xds.py $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xds.py cp pypad_xds.exemplar $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xds.exemplar ../../../helpers/install_python.sh pypad_xmpad.py $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xmpad.py @@ -84,6 +86,8 @@ uninstall-local: rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_urlwrite.py rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_walltime.exemplar rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_walltime.py + rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xcmd.exemplar + rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xcmd.py rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xds.exemplar rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xds.py rm -f $(DESTDIR)$(prefix)/@RD_LIB_PATH@/rivendell/pypad/pypad_xmpad.exemplar @@ -117,6 +121,8 @@ EXTRA_DIST = pypad_ando.exemplar\ pypad_urlwrite.py\ pypad_walltime.exemplar\ pypad_walltime.py\ + pypad_xcmd.exemplar\ + pypad_xcmd.py\ pypad_xds.exemplar\ pypad_xds.py\ pypad_xmpad.exemplar\ diff --git a/apis/pypad/scripts/pypad_xcmd.exemplar b/apis/pypad/scripts/pypad_xcmd.exemplar new file mode 100644 index 00000000..5e067943 --- /dev/null +++ b/apis/pypad/scripts/pypad_xcmd.exemplar @@ -0,0 +1,143 @@ +; This is the configuration for the 'rlm_xcmd.py' script for +; Rivendell, which can be used to output Now/Next PAD data to one or more +; RDS encoders that support the X-Command protocol, such as the Pira.cz +; P132, P232, P332. + +; Section Header +; +; One section per remote X-Command configuration, starting with 'XCmd1' and +; working up consecutively +[XCmd1] + +; Two methods of connecting to the unit are supported: TCP/IP or serial. +; +; ***************************************************************************** +; TCP/IP Connection Settings +; IP Address +; +; The IP address of the UDP port to send updates to, in dotted-quad notation. +; If using a serial connection, leave this entry blank! +IpAddress=192.168.99.3 + +; TCP Port +; +; The TCP port number to send updates to, in the range 0 - 65,535. +TcpPort=8823 +; ***************************************************************************** + +; ***************************************************************************** +; Serial Connection Settings +; +; The device file that corresponds to the serial device that is connected +; to the unit. If using a TCP/IP connection, leave this entry blank! +;Device=/dev/ttyS0 + +; Serial Baud Rate (in bps) +Speed=9600 + +; Parity (0=none, 1=even, 2=odd) +Parity=0 + +; Number of bits per data 'word'. +WordSize=8 +; ***************************************************************************** + +; ***************************************************************************** +; RDS CONFIGURATION +; ***************************************************************************** + +; ----------------------------------------------------------------------------- +; DestCode - How the text will appear on the receiver +; ----------------------------------------------------------------------------- +; 1 - Radiotext +; 3 - Radiotext incl. RT+ +; 4 - Dynamic PS +; 5 - Radiotext and Dynamic PS +; 7 - Radiotext incl. RT+ and Dynamic PS (Default) +DestCode=7 + +; ----------------------------------------------------------------------------- +; RadioText Plus Tags +; ----------------------------------------------------------------------------- +; StationNameShort - Stationname.Short +; StationNameLong - Stationname.Long +; Page - Program.Homepage +; Phone - Phone.Studio +; SMS - SMS.Studio +; Email - Email.Studio +StationNameShort=MY99.7 +StationNameLong=SPRING CREEK'S POSITIVE MIX +URL=http://www.my997.org/ +Phone=775-555-1212 +SMS=775-555-1212 +Email=email@mydomain.com + +; ----------------------------------------------------------------------------- +; RadioText - Text format to send to RDS Encoder +; ----------------------------------------------------------------------------- +; The PAD data to output each time RDAirPlay changes play state, including any +; wildcards as placeholders for metadata values. +; +; In addition to the standard Rivendell wildcards defined in the 'Metadata +; Wildcards' section of the Rivendell Operations Guide, the following +; wildcards are supported: +; +; %1 - StationNameShort +; %2 - StationNameLong +; %3 - URL +; %4 - Phone +; %5 - SMS +; %6 - Email +RadioText=Now playing on %1 %a %t + +; ----------------------------------------------------------------------------- +; UserDefinedPrefix +; ----------------------------------------------------------------------------- +; Prefix to identify custom RadioText format in a cart's "User Defined" field. +; If a cart contains a custom RadioText format stringit will override +; "RadioText" above. +; +; Example User Defined: +; xcmd:New Music on %1 %a %t +UserDefinedPrefix=xcmd: + +; ----------------------------------------------------------------------------- +; DefaultText +; ----------------------------------------------------------------------------- +; RadioText string to send if the PAD data received is empty +DefaultText=%1 - %2 (%4) + +; ----------------------------------------------------------------------------- +; Log Selection +; ----------------------------------------------------------------------------- +; Set the status for each log to 'Yes', 'No' or 'Onair' to indicate whether +; state changes on that log should be output on this udp port. If set +; to 'Onair', then output will be generated only if RDAirPlays OnAir flag +; is active. +MasterLog=Yes +Aux1Log=Yes +Aux2Log=Yes +VLog101=No +VLog102=No +VLog103=No +VLog104=No +VLog105=No +VLog106=No +VLog107=No +VLog108=No +VLog109=No +VLog110=No +VLog111=No +VLog112=No +VLog113=No +VLog114=No +VLog115=No +VLog116=No +VLog117=No +VLog118=No +VLog119=No +VLog120=No + + +; Additional RDS encoders can be configured by adding new sections... +;[XCmd2] diff --git a/apis/pypad/scripts/pypad_xcmd.py b/apis/pypad/scripts/pypad_xcmd.py new file mode 100755 index 00000000..37c4dc78 --- /dev/null +++ b/apis/pypad/scripts/pypad_xcmd.py @@ -0,0 +1,210 @@ +#!%PYTHON_BANGPATH% + +# pypad_xcmd.py +# +# Send Now & Next updates to an RDS encoder supporting X-Command +# +# (C) Copyright 2019 Fred Gleason +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with this program; if not, write to the Free Software +# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +# + +import os +import sys +import syslog +import socket +import configparser +import serial +import xml.etree.ElementTree as ET +import time +import pypad + + +def eprint(*args,**kwargs): + print(pypad_name+': ',file=sys.stderr,end='') + print(*args,file=sys.stderr) + syslog.syslog(syslog.LOG_ERR,*args) + +def iprint(*args,**kwargs): + print(pypad_name+': ',file=sys.stdout,end='') + print(*args,file=sys.stdout) + syslog.syslog(syslog.LOG_INFO,*args) + +def dprint(*args,**kwargs): + print(pypad_name+': ',file=sys.stdout,end='') + print(*args,file=sys.stdout) + syslog.syslog(syslog.LOG_DEBUG,*args) + +def XcmdResponse(): + resp_code={b'+':'Command processed successfully',b'!':'Unknown command',b'-':'Invalid argument',b'/':'Command processed partially'} + + while(True): + try: + response=send_sock.recv(1) + if response==b'+': return True,response.decode('utf-8'),resp_code[response] + if response==b'!': return True,response.decode('utf-8'),resp_code[response] + if response==b'-': return True,response.decode('utf-8'),resp_code[response] + if response==b'/': return True,response.decode('utf-8'),resp_code[response] + + except: + return False,'','Exception' + +def ProcessPad(update): + n=1 + while(True): + section='XCmd'+str(n) + try: + rds=ET.Element('rds') + item=ET.SubElement(rds,'item') + + # Set destination code + dest=ET.SubElement(item,'dest') + try: + dest.text=update.config().get(section,'DestCode') + + except configparser.NoOptionError: + dest.text='7' + + if update.shouldBeProcessed(section) and update.hasPadType(pypad.TYPE_NOW): + radiotext=update.config().get(section,'RadioText') + + # Use pypad string in USER_DEFINED field if available + try: + prefix=update.config().get(section,'UserDefinedPrefix').lower() + i=update.padField(pypad.TYPE_NOW,pypad.FIELD_USER_DEFINED).lower().find(prefix) + if i>-1: + radiotext=update.padField(pypad.TYPE_NOW,pypad.FIELD_USER_DEFINED)[i+len(prefix):].strip() + + except configparser.NoOptionError: + pass + + radiotext=radiotext.replace('%a','%a') + radiotext=radiotext.replace('%t','%t') + radiotext=radiotext.replace('%l','%l') + + radiotext=update.resolvePadFields(radiotext,pypad.ESCAPE_NONE) + else: + try: + radiotext=update.config().get(section,'DefaultText') + + except configparser.NoOptionError: + radiotext='' + + if '%1' in radiotext: + if update.config().get(section,'StationNameShort'): + radiotext=radiotext.replace('%1',''+update.config().get(section,'StationNameShort')+'') + + if '%2' in radiotext: + if update.config().get(section,'StationNameLong'): + radiotext=radiotext.replace('%2',''+update.config().get(section,'StationNameLong')+'') + + if '%3' in radiotext: + if update.config().get(section,'URL'): + radiotext=radiotext.replace('%3',''+update.config().get(section,'URL')+'') + + if '%4' in radiotext: + if update.config().get(section,'Phone'): + radiotext=radiotext.replace('%4',''+update.config().get(section,'Phone')+'') + + if '%5' in radiotext: + if update.config().get(section,'SMS'): + radiotext=radiotext.replace('%5',''+update.config().get(section,'SMS')+'') + + if '%6' in radiotext: + if update.config().get(section,'Email'): + radiotext=radiotext.replace('%6',''+update.config().get(section,'Email')+'') + + text=ET.SubElement(item,'text') + text.text=radiotext + + xcmd=b'XCMD='+ET.tostring(rds)+b"\r" + xcmd=xcmd.replace(b'<',b'<') + xcmd=xcmd.replace(b'>',b'>') + xcmd=xcmd.replace(b'&',b'&') + + try: + # + # Use serial output + # + tty_dev=update.config().get(section,'Device') + speed=int(update.config().get(section,'Speed')) + parity=serial.PARITY_NONE + if int(update.config().get(section,'Parity'))==1: + parity=serial.PARITY_EVEN + if int(update.config().get(section,'Parity'))==2: + parity=serial.PARITY_ODD + bytesize=int(update.config().get(section,'WordSize')) + dev=serial.Serial(tty_dev,speed,parity=parity,bytesize=bytesize) + dev.write(xcmd.decode('utf-8')) + dev.close() + + except configparser.NoOptionError: + # + # Create Send TCP Socket + # + send_sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM) + send_sock.settimeout(5) + + encoder=(update.config().get(section,'IpAddress'),int(update.config().get(section,'TcpPort'))) + iprint('Connecting to {}:{}'.format(*encoder)) + iprint(xcmd.decode('utf-8')) + + try: + send_sock.connect(encoder) + send_sock.sendall(xcmd) + ack,response,respstr=XcmdResponse() + if response: + iprint(respstr) + + except OSError as e: + eprint("Socket error: {0}".format(e)) + + except IOError as e: + errno,strerror=e.args + eprint("I/O error({0}): {1}".format(errno,strerror)) + + iprint('Closing connection') + send_sock.close() + + # Give the device time to process and close before sending another command + time.sleep(1) + + n=n+1 + except configparser.NoSectionError: + return + +# +# 'Main' function +# + +# +# Program Name +# +pypad_name=os.path.basename(__file__) + +# +# Open Syslog +# +syslog.openlog(pypad_name,logoption=syslog.LOG_PID,facility=syslog.LOG_DAEMON) + +rcvr=pypad.Receiver() +try: + rcvr.setConfigFile(sys.argv[3]) +except IndexError: + eprint('pypad_xcmd.py: USAGE: cmd ') + sys.exit(1) +rcvr.setPadCallback(ProcessPad) +iprint('Started') +rcvr.start(sys.argv[1],int(sys.argv[2])) +iprint('Stopped') diff --git a/apis/pypad/tests/Makefile.am b/apis/pypad/tests/Makefile.am index 416fdfb5..fac97bc9 100644 --- a/apis/pypad/tests/Makefile.am +++ b/apis/pypad/tests/Makefile.am @@ -22,7 +22,8 @@ EXTRA_DIST = filepath_test.py\ now_and_next.py\ - pad_test.py + pad_test.py\ + xcmd_server.py CLEANFILES = *~\ *.idb\ diff --git a/apis/pypad/tests/xcmd_server.py b/apis/pypad/tests/xcmd_server.py new file mode 100755 index 00000000..a678d7f4 --- /dev/null +++ b/apis/pypad/tests/xcmd_server.py @@ -0,0 +1,102 @@ +#!%PYTHON_BANGPATH% + +# xcmd_server.py +# +# pypad regression test script for Rivendell +# +# Exercise every PAD accessor method of 'pypad.Update' for each update. +# +# (C) Copyright 2019 Patrick Linstruth +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with this program; if not, write to the Free Software +# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +# + +import socketserver +import xml.etree.ElementTree as ET + +class XcmdTCPHandler(socketserver.BaseRequestHandler): + + count=0; + + def handle(self): + while True: + try: + self.data = self.request.recv(1024).strip() + + except: + print("Recv exception\n"); + break + + if not self.data: + print("No data\n"); + break + + if self.data[0] == 0xff: + print("0xff\n"); + break + + XcmdTCPHandler.count=XcmdTCPHandler.count+1 + + print("{0}:{1} wrote:".format(*self.client_address)) + print(self.data) + + try: + command=self.data.decode('utf-8').split(u'=') + + except: + break; + + print(command) + + if command[0].upper() == 'XCMD': + + print("Received XCMD\n") + + try: + xml=ET.fromstring(command[1]) + + # Don't respond to every 4th request + if XcmdTCPHandler.count%4: + self.request.sendall(b"+\r\n\r\n") + + except: + self.request.sendall(b"-\r\n\r\n") + + else: + self.request.sendall(b"!\r\n\r\n") + + + def finish(self): + print("{0}:{1} disconnected".format(*self.client_address)) + +if __name__ == "__main__": + HOST, PORT = "localhost", 1099 + + # Create the server, binding to localhost on port 1099 + server = socketserver.TCPServer((HOST, PORT), XcmdTCPHandler) + + # Activate the server; this will keep running until you + # interrupt the program with Ctrl-C + try: + server.serve_forever() + + except: + print("Shutdown\n") + server.shutdown() + + finally: + print("Close\n") + server.server_close() + + print("Stopped\n")