#!/usr/bin/env python #!/usr/bin/env python #------------------------------------------------------------------------------- # FILE: gengpioin.py # PURPOSE: genmon.py support program to allow amazon alexa voice commands # # AUTHOR: Jason G Yates # DATE: 27-Jul-2019 # # MODIFICATIONS: #------------------------------------------------------------------------------- # ############################################################################################## #### BASED ON WORK BY : https://github.com/nassir-malik/IOT-Pi3-Alexa-Automation #### ############################################################################################## #### To set up the Alexa Inegration, go to your Echo speaker and ask Alexa #### to discover your device. Say, "Discover my devices," or select Add #### Device in the Devices section of the Alexa app. #### #### Once Alexa has discovered your generator, you can use the Alexa app to #### complete the setup. #### #### To turn on your generator via the Echo speaker, say "Alexa, turn on #### the generator". And to off the generator again, "Alexa, turn off #### the generator". #### #### If you prefer to state the likes of "Alexa, start my generator" #### or "Alexa, stop my generator" (rather than using the words ON or #### OFF), you can set up a Routine with Alexa. import email.utils, requests, select, socket, struct, sys, datetime, time, urllib, uuid, signal, os, threading import atexit, getopt, json import fcntl, re, time, locale, socket, subprocess, traceback try: from genmonlib.mylog import SetupLogger from genmonlib.myconfig import MyConfig from genmonlib.myclient import ClientInterface from genmonlib.mysupport import MySupport from genmonlib.mycommon import MyCommon from genmonlib.program_defaults import ProgramDefaults except Exception as e1: print("\n\nThis program requires the modules located in the genmonlib directory in the github repository.\n") print("Please see the project documentation at https://github.com/jgyates/genmon.\n") print("Error: " + str(e1)) sys.exit(2) # This XML is the minimum needed to define one of our virtual switches # to the Amazon Echo SETUP_XML ="""<?xml version=1.0?> <root> <device> <deviceType>urn:Belkin:device:controllee:1</deviceType> <friendlyName>%(device_name)s</friendlyName> <manufacturer>Belkin International Inc.</manufacturer> <modelName>Socket</modelName> <modelNumber>3.1415</modelNumber> <modelDescription>Belkin Plugin Socket 1.0</modelDescription>\r\n <UDN>uuid:Socket-1_0-%(device_serial)s</UDN> <serialNumber>221517K0101767</serialNumber> <binaryState>0</binaryState> <serviceList> <service> <serviceType>urn:Belkin:service:basicevent:1</serviceType> <serviceId>urn:Belkin:serviceId:basicevent1</serviceId> <controlURL>/upnp/control/basicevent1</controlURL> <eventSubURL>/upnp/event/basicevent1</eventSubURL> <SCPDURL>/eventservice.xml</SCPDURL> </service> </serviceList> </device> </root>""" # A simple utility class to wait for incoming data to be # ready on a socket. #---------- poller class ------------------------------------------------------ class poller(MyCommon): # ---------------- poller.init --------------------------------------------- def __init__(self, log = None, debug = False): super(poller, self).__init__() self.log = log self.debug = debug try: self.poller = select.poll() self.targets = {} except Exception as e1: self.LogErrorLine("Error in poller init: " + str(e1)) sys.exit(1) # ---------------- poller.add ---------------------------------------------- def add(self, target, fileno = None): if not fileno: fileno = target.fileno() self.poller.register(fileno, select.POLLIN) self.targets[fileno] = target # ---------------- poller.remove ------------------------------------------- def remove(self, target, fileno = None): if not fileno: fileno = target.fileno() self.poller.unregister(fileno) del(self.targets[fileno]) # ---------------- poller.poll --------------------------------------------- def poll(self, timeout = 0): ready = self.poller.poll(timeout) num = len(ready) for one_ready in ready: target = self.targets.get(one_ready[0], None) if target: target.do_read(one_ready[0]) return num # Base class for a generic UPnP device. This is far from complete # but it supports either specified or automatic IP address and port # selection. #---------- upnp_device class ------------------------------------------------- class upnp_device(MyCommon): this_host_ip = None # ---------------- upnp_device.local_ip_address ---------------------------- @staticmethod def local_ip_address(): if not upnp_device.this_host_ip: temp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: temp_socket.connect(('8.8.8.8', 53)) upnp_device.this_host_ip = temp_socket.getsockname()[0] except: upnp_device.this_host_ip = '127.0.0.1' del(temp_socket) return upnp_device.this_host_ip # ---------------- upnp_device.init ---------------------------------------- def __init__(self, listener, poller, port, root_url, server_version, persistent_uuid, other_headers = None, ip_address = None, log = None, debug = False): super(upnp_device, self).__init__() self.log = log self.debug = debug try: self.listener = listener self.poller = poller self.port = port self.root_url = root_url self.server_version = server_version self.persistent_uuid = persistent_uuid self.uuid = uuid.uuid4() self.other_headers = other_headers if ip_address: self.ip_address = ip_address else: self.ip_address = upnp_device.local_ip_address() if self.ip_address == None: self.LogErrorLine("Error : unable to get IP address in upnp_device init") sys.exit(1) self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.bind((self.ip_address, self.port)) self.socket.listen(5) if self.port == 0: self.port = self.socket.getsockname()[1] self.poller.add(self) self.client_sockets = {} self.listener.add_device(self) except Exception as e1: self.LogErrorLine("Error in upnp_device init: " + str(e1)) sys.exit(1) # ---------------- upnp_device.fileno -------------------------------------- def fileno(self): return self.socket.fileno() # ---------------- upnp_device.do_read ------------------------------------- def do_read(self, fileno): if fileno == self.socket.fileno(): (client_socket, client_address) = self.socket.accept() self.poller.add(self, client_socket.fileno()) self.client_sockets[client_socket.fileno()] = (client_socket, client_address) else: data, sender = self.client_sockets[fileno][0].recvfrom(4096) if not data: self.poller.remove(self, fileno) del(self.client_sockets[fileno]) else: self.handle_request(data, sender, self.client_sockets[fileno][0], self.client_sockets[fileno][1]) # ---------------- upnp_device.handle_request ------------------------------ def handle_request(self, data, sender, socket, client_address): pass # ---------------- upnp_device.get_name ------------------------------------ def get_name(self): return "unknown" # ---------------- upnp_device.respond_to_search --------------------------- def respond_to_search(self, destination, search_target): self.LogDebug("Responding to search for %s" % self.get_name()) date_str = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) location_url = self.root_url % {'ip_address' : self.ip_address, 'port' : self.port} message = ("HTTP/1.1 200 OK\r\n" "CACHE-CONTROL: max-age=86400\r\n" "DATE: %s\r\n" "EXT:\r\n" "LOCATION: %s\r\n" "OPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n" "01-NLS: %s\r\n" "SERVER: %s\r\n" "ST: %s\r\n" "USN: uuid:%s::%s\r\n" % (date_str, location_url, self.uuid, self.server_version, search_target, self.persistent_uuid, search_target)) if self.other_headers: for header in self.other_headers: message += "%s\r\n" % header message += "\r\n" self.LogDebug(message) temp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) temp_socket.sendto(message.encode("UTF-8"), destination) # This subclass does the bulk of the work to mimic a WeMo switch on the network. #---------- fauxmo class ------------------------------------------------------ class fauxmo(upnp_device): # ---------------- fauxmo.make_uuid ---------------------------------------- @staticmethod def make_uuid(name): return ''.join(["%x" % sum([ord(c) for c in name])] + ["%x" % ord(c) for c in "%sfauxmo!" % name])[:14] # ---------------- fauxmo.__init__ ----------------------------------------- def __init__(self, name, listener, poller, ip_address, port, action_handler = None, log = None, debug = False): self.log = log self.debug = debug try: self.serial = self.make_uuid(name) self.name = name self.ip_address = ip_address self.generatorStatus = 0 persistent_uuid = "Socket-1_0-" + self.serial other_headers = ['X-User-Agent: redsonic'] super(fauxmo, self).__init__(listener, poller, port, "http://%(ip_address)s:%(port)s/setup.xml", "Unspecified, UPnP/1.0, Unspecified", persistent_uuid, other_headers=other_headers, ip_address=ip_address, log = log, debug = debug) if action_handler: self.action_handler = action_handler else: self.action_handler = self self.LogDebug("FauxMo device '%s' ready on %s:%s" % (self.name, self.ip_address, self.port)) except Exception as e1: self.LogErrorLine("Error in fauxmo init: " + str(e1)) sys.exit(1) # ---------------- fauxmo.get_name ----------------------------------------- def get_name(self): return self.name # ---------------- fauxmo.handle_request ----------------------------------- def handle_request(self, data, sender, socket, client_address): data = data.decode('utf-8') success = False if data.find('GET /setup.xml HTTP/1.1') == 0: self.LogDebug("Responding to setup.xml for %s" % self.name) xml = SETUP_XML % {'device_name' : self.name, 'device_serial' : self.serial} date_str = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) message = ("HTTP/1.1 200 OK\r\n" "CONTENT-LENGTH: %d\r\n" "CONTENT-TYPE: text/xml\r\n" "DATE: %s\r\n" "LAST-MODIFIED: Sat, 01 Jan 2000 00:01:15 GMT\r\n" "SERVER: Unspecified, UPnP/1.0, Unspecified\r\n" "X-User-Agent: redsonic\r\n" "CONNECTION: close\r\n" "\r\n" "%s" % (len(xml), date_str, xml)) self.LogDebug(message) socket.send(message.encode("UTF-8")) elif data.find('SOAPACTION: "urn:Belkin:service:basicevent:1#SetBinaryState"') != -1: success = False if data.find('SetBinaryState') != -1: if data.find('<BinaryState>1</BinaryState>') != -1: # on self.LogDebug("Responding to ON for %s" % self.name) success = self.action_handler.on() if success: self.generatorStatus = 1 elif data.find('<BinaryState>0</BinaryState>') != -1: # off self.LogDebug("Responding to OFF for %s" % self.name) success = self.action_handler.off() if success: self.generatorStatus = 0 else: self.LogError("Unknown Binary State request:") self.LogError(data) if success: # The echo is happy with the 200 status code and doesn't # appear to care about the SOAP response body self.LogDebug("Successfully Completed Action") soap = "" date_str = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) message = ("HTTP/1.1 200 OK\r\n" "CONTENT-LENGTH: %d\r\n" "CONTENT-TYPE: text/xml charset=\"utf-8\"\r\n" "DATE: %s\r\n" "EXT:\r\n" "SERVER: Unspecified, UPnP/1.0, Unspecified\r\n" "X-User-Agent: redsonic\r\n" "CONNECTION: close\r\n" "\r\n" "%s" % (len(soap), date_str, soap)) socket.send(message.encode("UTF-8")) elif data.find('GetBinaryState'): self.generatorStatus = self.action_handler.status(self.generatorStatus) self.LogDebug("Responding to provide current state: " + str(self.generatorStatus)) soap = """<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> <s:Body> <u:GetBinaryStateResponse xmlns:u="urn:Belkin:service:basicevent:1"> <BinaryState>"""+ str(self.generatorStatus) +"""</BinaryState> </u:GetBinaryStateResponse> </s:Body></s:Envelope>""" date_str = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) message = ("HTTP/1.1 200 OK\r\n" "CONTENT-LENGTH: %d\r\n" "CONTENT-TYPE: text/xml charset=\"utf-8\"\r\n" "DATE: %s\r\n" "EXT:\r\n" "SERVER: Unspecified, UPnP/1.0, Unspecified\r\n" "X-User-Agent: redsonic\r\n" "CONNECTION: close\r\n" "\r\n" "%s" % (len(soap), date_str, soap)) socket.send(message.encode("UTF-8")) self.LogDebug("SEND RESPONSE: "+str(message.replace('\n','\\n').replace('\r','\\r'))) else: self.LogError("Unknown data:") self.LogError(str(data)) # ---------------- fauxmo.on ----------------------------------------------- def on(self): return False # ---------------- fauxmo.off ---------------------------------------------- def off(self): return True # Since we have a single process managing several virtual UPnP devices, # we only need a single listener for UPnP broadcasts. When a matching # search is received, it causes each device instance to respond. # # Note that this is currently hard-coded to recognize only the search # from the Amazon Echo for WeMo devices. In particular, it does not # support the more common root device general search. The Echo # doesn't search for root devices. #---------- upnp_broadcast_responder class ------------------------------------ class upnp_broadcast_responder(MyCommon): TIMEOUT = 0 # ---------------- upnp_broadcast_responder.init --------------------------- def __init__(self, log = None, debug = False): super(upnp_broadcast_responder, self).__init__() self.log = log self.debug = debug self.devices = [] # ---------------- upnp_broadcast_responder.init_socket -------------------- def init_socket(self): ok = True self.ip = '239.255.255.250' self.port = 1900 try: #This is needed to join a multicast group self.mreq = struct.pack("4sl",socket.inet_aton(self.ip),socket.INADDR_ANY) #Set up server socket self.ssock = socket.socket(socket.AF_INET,socket.SOCK_DGRAM,socket.IPPROTO_UDP) self.ssock.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) try: self.ssock.bind(('',self.port)) except Exception as e: self.LogError("WARNING: Failed to bind %s:%d: %s" , (self.ip,self.port,e)) ok = False try: self.ssock.setsockopt(socket.IPPROTO_IP,socket.IP_ADD_MEMBERSHIP,self.mreq) except Exception as e: self.LogError('WARNING: Failed to join multicast group:'+str(e)) ok = False except Exception as e: self.LogError("Failed to initialize UPnP sockets:"+str(e)) return False if ok: self.LogDebug("Listening for UPnP broadcasts") # ---------------- upnp_broadcast_responder.fileno ------------------------- def fileno(self): return self.ssock.fileno() # ---------------- upnp_broadcast_responder.do_read ------------------------ def do_read(self, fileno): data, sender = self.recvfrom(1024) data = data.decode('utf-8') if data: if data.find('M-SEARCH') >= 0 and data.find('urn:Belkin:device:**') >0 or data.find('n:Belkin:device:**') >0 or data.find('upnp:rootdevice') >0: for device in self.devices: time.sleep(0.5) device.respond_to_search(sender, 'urn:Belkin:device:**') else: pass # ---------------- upnp_broadcast_responder.recvfrom ----------------------- #Receive network data def recvfrom(self,size): if self.TIMEOUT: self.ssock.setblocking(0) ready = select.select([self.ssock], [], [], self.TIMEOUT)[0] else: self.ssock.setblocking(1) ready = True try: if ready: return self.ssock.recvfrom(size) else: return False, False except Exception as e: self.LogError("recvfrom exception: "+str(e)) return False, False def add_device(self, device): self.devices.append(device) self.LogDebug("UPnP broadcast listener: new device registered") # This is an example handler class. The fauxmo class expects handlers to be # instances of objects that have on() and off() methods that return True # on success and False otherwise. # # This example class takes two full URLs that should be requested when an on # and off command are invoked respectively. It ignores any return data. class FauxmoCallback(MyCommon): """Use this DEBOUNCE_SECONDS to keep multiple Amazon Echo devices from reacting to the same voice command. """ DEBOUNCE_SECONDS = 0.3 STATUS_INTERVAL = 10 # ---------------- FauxmoCallback.init ------------------------------------- def __init__(self, cmd, log = None, debug = False): super(FauxmoCallback, self).__init__() self.cmd = cmd self.log = log self.debug = debug self.lastEcho = time.time() self.lastStatus = time.time() # ---------------- FauxmoCallback.on --------------------------------------- def on(self): if self.debounce(): return True try: returnValue = self.cmd("generator: setremote=starttransfer") ## FOR TESTING ## returnValue = self.cmd("generator: setremote=start") ## FOR TESTING ## returnValue = self.cmd("generator: setremote=stop") self.LogDebug("Sent Remote Start Command. Return Value: "+returnValue) if returnValue != "Remote command sent successfully": self.LogError("Command Failed") return False except Exception as e1: LogErrorLine("Error FauxmoCallback.on: " + str(e1)) return False return True # ---------------- FauxmoCallback.off -------------------------------------- def off(self): if self.debounce(): return True try: returnValue = self.cmd("generator: setremote=stop") self.LogDebug("Sent Remote Stop Command. Return Value: "+returnValue) if returnValue != "Remote command sent successfully": self.LogError("Command Failed") return False except Exception as e1: self.LogErrorLine("Error FauxmoCallback.off: " + str(e1)) return False return True # ---------------- FauxmoCallback.status ----------------------------------- def status(self, currentStatus): """Ensure the generators status is not checked too often if many Echos are present """ if (time.time() - self.lastStatus) < self.STATUS_INTERVAL: return currentStatus self.lastStatus = time.time() try: returnValue = self.cmd("generator: getbase") self.LogDebug("Sent GETBASE Command. Return Value: "+returnValue) if "RUNNING" in returnValue: currentStatus = 1 else: currentStatus = 0 except Exception as e1: LogErrorLine("Error StopCallback: " + str(e1)) return currentStatus return currentStatus # ---------------- FauxmoCallback.debounce --------------------------------- def debounce(self): """If multiple Echos are present, the one most likely to respond first is the one that can best hear the speaker... which is the closest one. Adding a refractory period to handlers keeps us from worrying about one Echo overhearing a command meant for another one. """ if (time.time() - self.lastEcho) < self.DEBOUNCE_SECONDS: return True self.lastEcho = time.time() return False #---------- Signal Handler ---------------------------------------------------- def signal_handler(signal, frame): try: MyClientInterface.Close() except Exception as e1: log.error("Error: signal_handler: " + str(e1)) sys.exit(0) #------------------- Command-line interface for gengpioin ---------------------- if __name__=='__main__': address=ProgramDefaults.LocalHost try: console = SetupLogger("genalexa_console", log_file = "", stream = True) if os.geteuid() != 0: console.error("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.") sys.exit(2) HelpStr = '\nsudo python genalexa.py -a <IP Address or localhost> -c <path to genmon config file>\n' try: ConfigFilePath = ProgramDefaults.ConfPath opts, args = getopt.getopt(sys.argv[1:],"hc:a:",["help","configpath=","address="]) except getopt.GetoptError: console.error("Invalid command line argument.") sys.exit(2) for opt, arg in opts: if opt == '-h': console.error(HelpStr) sys.exit() elif opt in ("-a", "--address"): address = arg elif opt in ("-c", "--configpath"): ConfigFilePath = arg ConfigFilePath = ConfigFilePath.strip() except Exception as e1: console.error("Error in init: " + str(e1) + " : " + GetErrorLine()) sys.exit(1) try: port, loglocation = MySupport.GetGenmonInitInfo(ConfigFilePath, log = console) log = SetupLogger("client", loglocation + "genalexa.log") except Exception as e1: print("Error setting up log: " + str(e1)) sys.exit(1) try: # Set the signal handler signal.signal(signal.SIGINT, signal_handler) if not os.path.isfile(ConfigFilePath + 'genalexa.conf'): console.error("Error: config file not found") log.error("Error: config file not found") sys.exit(1) config = MyConfig(filename = ConfigFilePath + 'genalexa.conf', section = 'genalexa', log = log) FauxmoName = config.ReadValue('name', default = " generator") FauxmoPort = config.ReadValue('port', return_type = int, default = 52004) Debug = config.ReadValue('debug', return_type = bool, default = False) log.error("Key word: " + FauxmoName + "; Port: "+str(FauxmoPort) + "; " + "Debug: " + str(Debug)) MyClientInterface = ClientInterface(host = address, port = port, log = log) data = MyClientInterface.ProcessMonitorCommand("generator: start_info_json") StartInfo = {} StartInfo = json.loads(data) remoteCommands = False if 'RemoteCommands' in StartInfo: remoteCommands = StartInfo['RemoteCommands'] if remoteCommands == False: log.error("Generator does not support remote commands. So you cannot use this addon. Exiting....") sys.exit(1) FauxmoAction = FauxmoCallback(MyClientInterface.ProcessMonitorCommand, log = log, debug = Debug) # Set up our singleton for polling the sockets for data ready p = poller(log = log) # Set up our singleton listener for UPnP broadcasts u = upnp_broadcast_responder(log = log, debug = Debug) u.init_socket() # Add the UPnP broadcast listener to the poller so we can respond # when a broadcast is received. p.add(u) switch = fauxmo(FauxmoName, u, p, None, FauxmoPort, action_handler = FauxmoAction, log = log, debug = Debug) if Debug: log.info("Entering main loop") while True: try: # Allow time for a ctrl-c to stop the process p.poll(100) time.sleep(0.1) except Exception as e: log.error("Exception occured in main loop: " + str(e)) time.sleep(60) # break except Exception as e1: log.error("Error : " + str(e1)) console.error("Error: " + str(e1))