def get_state(self) -> str: """Get device state. Returns: "on", "off", or "unknown" If fake_state is set to true, it does not exec a query for status, it returns the previous status stored. """ if self.fake_state: logger.info(f"ZwavePlugin: return fake {self.state} ") return self.state url = ("http://" + self.zwave_host + ":" + str(self.zwave_port) + "/JS/Run/controller.devices.get('" + self.zwave_device + "').get('metrics:level')") logger.info(f"ZwavePlugin: Getting {url} ") try: resp = requests.get(url, auth=(self.zwave_user, self.zwave_pass)) except Exception as e: logger.error(f"ZwavePlugin: {e}") return "unknown" if resp.status_code == 200: if resp.text == '"off"' or resp.text == '"on"': return resp.text.strip('"') logger.error(f"ZwavePlugin: {resp.status_code} {resp.text} ") return "unknown"
def _toggle(self, state: bool) -> None: "Run the TOGGLE command. Returns true if command succeeded" GPIO.output(self.output_pin, False) logger.info(f"{self.name}: Turned to --> {not self.state}") sleep(0.1) GPIO.output(self.output_pin, True) logger.info(f"{self.name}: Turned back to --> {self.state}") self.state = state
def close(self) -> None: "Shut down cleanly" self.loop_running = False self.loop.run_until_complete(self.task) self.set_state(False, "shutdown") FauxmoGpioPlugin._num_instances -= 1 if (FauxmoGpioPlugin._num_instances == 0): GPIO.cleanup() logger.info(f"{self.name}: Shutdown complete")
def data_received(self, data: bytes) -> None: """Decode incoming data. Args: data: Incoming message, either setup request or action request """ msg = data.decode() logger.debug(f"Received message:\n{msg}") if msg.startswith('GET /setup.xml HTTP/1.1'): logger.info("setup.xml requested by Echo") self.handle_setup() elif msg.startswith('POST /upnp/control/basicevent1 HTTP/1.1'): self.handle_action(msg)
def set_state(self, state: bool, reason: str = "unspecified") -> None: "Set the plugin into the given state" if (state == self.state): return self.state = state if self.output_pin: GPIO.output(self.output_pin, self.state) elif state: _run_cmd(self.output_cmds[0]) else: _run_cmd(self.output_cmds[1]) if (self.notification_pin): GPIO.output(self.notification_pin, self.state) newval = "ON" if self.state else "OFF" logger.info(f"{self.name}: Turned {newval} on {reason}")
async def timer(self): "Timer loop to watch for schedule events." while (self.loop_running): if datetime.now(self.timezone).date() > self.sched_reset_for: self.reset_schedule() # process any schedule events if self.state: now = datetime.now(self.timezone).time() for e in self.schedule: if now > e['time'] and not e['processed']: self.set_pair_state(e['value']) e['processed'] = True # only sleep 1 sec, otherwise teardown time is too long await asyncio.sleep(1) logger.info(f"{self.name}: timer exiting")
def _ZwaveCmd(self, cmd: str) -> bool: url = ("http://" + self.zwave_host + ":" + str(self.zwave_port) + "/ZAutomation/api/v1/devices/" + self.zwave_device + "/command/" + cmd) logger.info(f"ZwavePlugin: Getting {url} ") try: resp = requests.get(url, auth=(self.zwave_user, self.zwave_pass)) except Exception as e: logger.error(f"ZwavePlugin: {e}") return False if resp.status_code == 200: if resp.text == response_ok: self.state = cmd return True logger.error(f"ZwavePlugin: {resp.status_code} {resp.text} ") return False
def __init__( self, *, name: str, port: int, device: str, zwave_host: str = "localhost", zwave_port: int = 8083, zwave_user: str = "admin", zwave_pass: str = None, fake_state: bool = False, state: str = "unknown", ) -> None: """Initialize a ZwaveAPIPlugin instance. Args: zwave_host: IP address or dns name of zway-server zwave_port: TCP port running zway-server (default 8083) zwave_user: Zwave user zwave_pass: Zwave user password fake_state: Set to true for it does not exec a query for status, it returns the previous status stored state: Initial device status """ self.zwave_host = zwave_host self.zwave_port = zwave_port self.zwave_user = zwave_user self.zwave_pass = zwave_pass self.zwave_device = device self.fake_state = fake_state logger.info(f"ZwavePlugin: {ZwavePlugin_version} " + "name: {name} device: {device} " + "port: {port} fake_state: {fake_state}") super().__init__(name=name, port=port)
def data_received(self, data: bytes) -> None: """Decode incoming data. Args: data: Incoming message, either setup request or action request """ msg = data.decode() logger.debug(f"Received message:\n{msg}") if msg.startswith("GET /setup.xml HTTP/1.1"): logger.info("setup.xml requested by Echo") self.handle_setup() elif "/eventservice.xml" in msg: logger.info("eventservice.xml request by Echo") self.handle_event() elif "/metainfoservice.xml" in msg: logger.info("metainfoservice.xml request by Echo") self.handle_metainfo() elif msg.startswith("POST /upnp/control/basicevent1 HTTP/1.1"): logger.info("request BasicEvent1") self.handle_action(msg)
def on(self) -> bool: self.state = True logger.info(f"{self.name}: Turned ON") return True
def main(config_path_str: str = None, verbosity: int = 20) -> None: """Run the main fauxmo process. Spawns a UDP server to handle the Echo's UPnP / SSDP device discovery process as well as multiple TCP servers to respond to the Echo's device setup requests and handle its process for turning devices on and off. Args: config_path_str: Path to config file. If not given will search for `config.json` in cwd, `~/.fauxmo/`, and `/etc/fauxmo/`. verbosity: Logging verbosity, defaults to 20 """ logger.setLevel(verbosity) logger.info(f"Fauxmo version {__version__}") logger.debug(sys.version) if config_path_str: config_path = pathlib.Path(config_path_str) else: for config_dir in ('.', "~/.fauxmo", "/etc/fauxmo"): config_path = pathlib.Path(config_dir) / 'config.json' if config_path.is_file(): logger.info(f"Using config: {config_path}") break try: config = json.loads(config_path.read_text()) except FileNotFoundError: logger.error("Could not find config file in default search path. Try " "specifying your file with `-c`.\n") raise # Every config should include a FAUXMO section fauxmo_config = config.get("FAUXMO") fauxmo_ip = get_local_ip(fauxmo_config.get("ip_address")) ssdp_server = SSDPServer() servers = [] loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) if verbosity < 20: loop.set_debug(True) logging.getLogger('asyncio').setLevel(logging.DEBUG) try: plugins = config['PLUGINS'] except KeyError: # Give a meaningful message without a nasty traceback if it looks like # user is running a pre-v0.4.0 config. errmsg = ("`PLUGINS` key not found in your config.\n" "You may be trying to use an outdated config.\n" "If so, please review <https://github.com/n8henrie/fauxmo> " "and update your config for Fauxmo >= v0.4.0.") print(errmsg) sys.exit(1) for plugin in plugins: modname = f"{__package__}.plugins.{plugin.lower()}" try: module = importlib.import_module(modname) # Will fail until https://github.com/python/typeshed/pull/1083 merged # and included in the next mypy release except ModuleNotFoundError: # type: ignore path_str = config['PLUGINS'][plugin]['path'] module = module_from_file(modname, path_str) PluginClass = getattr(module, plugin) # noqa if not issubclass(PluginClass, FauxmoPlugin): raise TypeError(f"Plugins must inherit from {repr(FauxmoPlugin)}") # Pass along variables defined at the plugin level that don't change # per device plugin_vars = { k: v for k, v in config['PLUGINS'][plugin].items() if k not in {"DEVICES", "path"} } logger.debug(f"plugin_vars: {repr(plugin_vars)}") for device in config['PLUGINS'][plugin]['DEVICES']: logger.debug(f"device config: {repr(device)}") # Ensure port is `int`, set it if not given (`None`) or 0 device["port"] = int(device.get('port', 0)) or find_unused_port() try: plugin = PluginClass(**plugin_vars, **device) except TypeError: logger.error(f"Error in plugin {repr(PluginClass)}") raise fauxmo = partial(Fauxmo, name=plugin.name, plugin=plugin) coro = loop.create_server(fauxmo, host=fauxmo_ip, port=plugin.port) server = loop.run_until_complete(coro) servers.append(server) ssdp_server.add_device(plugin.name, fauxmo_ip, plugin.port) logger.debug(f"Started fauxmo device: {repr(fauxmo.keywords)}") logger.info("Starting UDP server") # mypy will fail until https://github.com/python/typeshed/pull/1084 merged, # pulled into mypy, and new mypy released listen = loop.create_datagram_endpoint( lambda: ssdp_server, # type: ignore sock=make_udp_sock()) transport, _ = loop.run_until_complete(listen) # type: ignore for signame in ('SIGINT', 'SIGTERM'): try: loop.add_signal_handler(getattr(signal, signame), loop.stop) # Workaround for Windows (https://github.com/n8henrie/fauxmo/issues/21) except NotImplementedError: if sys.platform == 'win32': pass else: raise loop.run_forever() # Will not reach this part unless SIGINT or SIGTERM triggers `loop.stop()` logger.debug("Shutdown starting...") transport.close() for idx, server in enumerate(servers): logger.debug(f"Shutting down server {idx}...") server.close() loop.run_until_complete(server.wait_closed()) loop.close()
def main(config_path=None, verbosity=20): """Runs the main fauxmo process Spawns a UDP server to handle the Echo's UPnP / SSDP device discovery process as well as multiple TCP servers to respond to the Echo's device setup requests and handle its process for turning devices on and off. Kwargs: config_path (str): Path to config file. If not given will search for `config.json` in cwd, `~/.fauxmo/`, and `/etc/fauxmo/`. verbosity (int): Logging verbosity, defaults to 20 """ logger.setLevel(verbosity) logger.debug(sys.version) if not config_path: config_dirs = ['.', os.path.expanduser("~/.fauxmo"), "/etc/fauxmo"] for config_dir in config_dirs: config_path = os.path.join(config_dir, 'config.json') if os.path.isfile(config_path): logger.info("Using config: {}".format(config_path)) break try: with open(config_path) as config_file: config = json.load(config_file) except FileNotFoundError: logger.error("Could not find config file in default search path. " "Try specifying your file with `-c` flag.\n") raise # Every config should include a FAUXMO section fauxmo_config = config.get("FAUXMO") fauxmo_ip = get_local_ip(fauxmo_config.get("ip_address")) ssdp_server = SSDPServer() servers = [] loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.set_debug(True) # Initialize Fauxmo devices for device in config.get('DEVICES'): name = device.get('description') port = int(device.get("port")) action_handler = RESTAPIHandler(**device.get("handler")) fauxmo = partial(Fauxmo, name=name, action_handler=action_handler) coro = loop.create_server(fauxmo, host=fauxmo_ip, port=port) server = loop.run_until_complete(coro) servers.append(server) ssdp_server.add_device(name, fauxmo_ip, port) logger.debug(fauxmo.keywords) # Initialize Home Assistant devices if config exists and enable is True if config.get("HOMEASSISTANT", {}).get("enable") is True: hass_config = config.get("HOMEASSISTANT") hass_host = hass_config.get("host") hass_password = hass_config.get("password") hass_port = hass_config.get("port") for device in hass_config.get('DEVICES'): name = device.get('description') device_port = device.get("port") entity = device.get("entity_id") action_handler = HassAPIHandler(host=hass_host, password=hass_password, entity=entity, port=hass_port) fauxmo = partial(Fauxmo, name=name, action_handler=action_handler) coro = loop.create_server(fauxmo, host=fauxmo_ip, port=device_port) server = loop.run_until_complete(coro) servers.append(server) ssdp_server.add_device(name, fauxmo_ip, device_port) logger.debug(fauxmo.keywords) logger.info("Starting UDP server") listen = loop.create_datagram_endpoint(lambda: ssdp_server, local_addr=('0.0.0.0', 1900), family=socket.AF_INET) transport, protocol = loop.run_until_complete(listen) for signame in ('SIGINT', 'SIGTERM'): loop.add_signal_handler(getattr(signal, signame), loop.stop) loop.run_forever() # Will not reach this part unless SIGINT or SIGTERM triggers `loop.stop()` logger.debug("Shutdown starting...") transport.close() for idx, server in enumerate(servers): logger.debug("Shutting down server {}...".format(idx)) server.close() loop.run_until_complete(server.wait_closed()) loop.close()
def handle_action(self, msg: str) -> None: """Execute `on`, `off`, or `get_state` method of plugin. Args: msg: Body of the Echo's HTTP request to trigger an action """ logger.debug(f"Handling action for plugin type {self.plugin}") success = False soap_format = ( '<s:Envelope ' 'xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ' 's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' '<s:Body>' '<u:{action}BinaryStateResponse ' 'xmlns:u="urn:Belkin:service:basicevent:1">' '<BinaryState>{state_int}</BinaryState>' '</u:{action}BinaryStateResponse>' '</s:Body>' '</s:Envelope>').format command_format = ( 'SOAPACTION: "urn:Belkin:service:basicevent:1#{}BinaryState"' ).format soap_message: str = None action: str = None state_int: int = None if command_format("Get") in msg: logger.info(f"Attempting to get state for {self.plugin.name}") action = "Get" try: state = self.plugin.get_state() except AttributeError: logger.warning(f"Plugin {self.plugin.__module__} has not " "implemented a `get_state` method.") else: logger.info(f"{self.plugin.name} state: {state}") success = True state_int = int(state.lower() == "on") elif command_format("Set") in msg: action = "Set" if '<BinaryState>0</BinaryState>' in msg: logger.info(f"Attempting to turn off {self.plugin.name}") state_int = 0 success = self.plugin.off() elif '<BinaryState>1</BinaryState>' in msg: logger.info(f"Attempting to turn on {self.plugin.name}") state_int = 1 success = self.plugin.on() else: logger.warning(f"Unrecognized request:\n{msg}") if success: date_str = formatdate(timeval=None, localtime=False, usegmt=True) soap_message = soap_format(action=action, state_int=state_int) response = '\n'.join([ 'HTTP/1.1 200 OK', f'CONTENT-LENGTH: {len(soap_message)}', 'CONTENT-TYPE: text/xml charset="utf-8"', f'DATE: {date_str}', 'EXT:', 'SERVER: Unspecified, UPnP/1.0, Unspecified', 'X-User-Agent: Fauxmo', 'CONNECTION: close\n', f'{soap_message}', ]) logger.debug(response) self.transport.write(response.encode()) else: errmsg = (f"Unable to complete command for {self.plugin.name}:\n" f"{msg}") logger.warning(errmsg) self.transport.close()
def off(self) -> bool: self.state = False logger.info(f"{self.name}: Turned OFF") return True
def __init__(self, name: str, port: int, paired_device: str, schedule_events: list, timezone: str = "UTC", latitude: float = None, longitude: float = None, initial_state: bool = True) -> None: """Initialize a SchedulerPlugin instance. Args: name: Name for this Fauxmo device port: Port on which to run this Fauxmo device paired_device: Name of the Fauxmo device controlled by this schedule instance. The plugin for the paired device must also derive from PairedFauxmoPlugin. schedule_events: A list of dicts of the form {"trigger": time-str, "random": int, "value": bool} time-str should follow the pattern HH:MM[:SS] (with hours in 24-hour format), or the patterns "sunrise[+N]", "sunrise[-N]", "sunset[+N]" or "sunset[-N]" (where N, if specified, is the number of minutes offset from sunrise or sunset). random, if specified, will cause the actual schedule event to happen randomly between 0 and N minutes after the time specified by time-str. value is true to turn on, false to turn off. timezone: The local timezone; if not specified, UTC is assumed. latitude, longitude: Coordinates on which sunrise and sunset should be calculated. Must be specified if any schedule triggers use sunrise or sunset. initial_state: A bool indicating whether the schedule is ON or OFF at startup. If not specified, ON is assumed. """ self.state = initial_state self.timezone = pytz.timezone(timezone) self.latitude = latitude self.longitude = longitude # Internally, we maintain the self.schedule list, which is a # list of schedule events. Each element in the list is a dict # of the form specified in the comment for _parse_sched_entry self.schedule = [] if schedule_events: for e in schedule_events: self.schedule.append(self._parse_sched_entry(e)) self.reset_schedule() logger.info(f"{name} parsed schedule:" + repr(self.schedule)) self.is_schedule_on = bool(self.schedule) self.loop = asyncio.get_event_loop() self.loop_running = True self.task = self.loop.create_task(self.timer()) super().__init__(name=name, port=port, paired_device_name=paired_device) logger.info(f"Fauxmo schedule device {self.name} initialized")
def close(self) -> None: "Shut down cleanly" self.loop_running = False self.loop.run_until_complete(self.task) logger.info(f"{self.name}: Shutdown complete")
async def gpio_timer(self): """Timer loop to receive switch events. If input_pin is not configured, this loop will not run.""" if self.long_press_interval: lp_interval = timedelta(milliseconds=self.long_press_interval) else: # setting this to 10 minutes is equivalent to disabling it! lp_interval = timedelta(seconds=600) press_tm = None notif_tog_tm = datetime.now() notif_delta = 0 local_is_schedule_on = not self.is_schedule_on() while (self.loop_running): if GPIO.input(self.input_pin): # button is depressed if not press_tm: press_tm = datetime.now() notif_tog_tm = datetime.now() notif_delta = (40, 80) # on time in msec, off time if (datetime.now() - press_tm) > lp_interval: notif_delta = 0 if (self.notification_pin): GPIO.output(self.notification_pin, True) elif press_tm: # button has been released if (datetime.now() - press_tm) < timedelta(milliseconds=50): logger.info(f"{self.name}: very short press, ignoring") elif (datetime.now() - press_tm) < lp_interval: # short press self.set_state(not self.state, "button press") else: # long press self.trigger_long_press() press_tm = None if self.is_schedule_on(): notif_delta = self.schedule_notification_interval else: notif_delta = 0 if (self.notification_pin): GPIO.output(self.notification_pin, self.state) if self.is_schedule_on() != local_is_schedule_on: if self.is_schedule_on(): notif_delta = self.schedule_notification_interval local_is_schedule_on = True else: notif_delta = 0 local_is_schedule_on = False if (notif_delta and self.notification_pin and datetime.now() >= notif_tog_tm): cur_val = GPIO.input(self.notification_pin) if type(notif_delta) is tuple: delta = notif_delta[cur_val] else: delta = notif_delta notif_tog_tm = datetime.now() + timedelta(milliseconds=delta) GPIO.output(self.notification_pin, not cur_val) await asyncio.sleep(0.02) logger.info(f"{self.name}: gpio_timer exiting")
def __init__(self, name: str, port: int, type: str = None, state: int = None, output_pin: int = None, output_cmds: list = None, input_pin: int = None, input_pull_dir: str = None, notification_pin: int = None, long_press_interval: int = None, long_press_action: str = None) -> None: """Initialize a FauxmoGpioPlugin instance. Args: name: Name for this Fauxmo device port: Port on which to run this instance --- must specify one of the following --- output_pin: RPi.GPIO pin (using BOARD numbering) to control output_cmds: a 2-element string array; first command will be run to turn the device "on", second will be run to turn it off --- from here down the args are optional --- input_pin: RPi.GPIO pin (using BOARD numbering) which maps to a momentary-contact input switch. When a rising edge is detected on this pin, the state of the device will be toggled. Default is for the input_pin to not be configured. input_pull_dir: Either "Down" or "Up". Default is Down. notification_pin: RPi.GPIO pin (using BOARD numbering) which maps to an LED. The LED will be used for user feedback while pressing buttons, and to indicate whether the schedule is set or not. long_press_interval: duration, in milliseconds, for the user to hold down the switch in order to trigger a long press. If set, holding the button down for this interval will cause the configured long_press_action to be executed. If not specified, no long press behavior will be recognized. long_press_action: the action to be taken when a long press occurs. Must be specified if long_press_interval is set. Can be either the special string "toggle_paired_device", or a command to be run. """ if (state is not None): self.state = state else: self.state = False # True = on, False = off # Don't need to validate the output_pin, input_pin etc; # RPi.GPIO will throw ValueError if a pin is illegal if (type == "toggle"): self.toggle = True else: self.toggle = False self.output_pin = output_pin self.output_cmds = output_cmds if self.output_pin is None: if self.output_cmds is None or len(self.output_cmds) != 2: raise ValueError("Must specify output_pin, or output_cmds") else: if self.output_cmds is not None: raise ValueError("Cannot specify both output_pin and " "output_cmds") self.input_pin = input_pin self.notification_pin = notification_pin if (not input_pull_dir or input_pull_dir.lower() == "down"): self.input_pull_dir = GPIO.PUD_DOWN elif input_pull_dir.lower() == "up": self.input_pull_dir = GPIO.PUD_UP else: raise ValueError(f"input_pull_dir must be either Up or Down, " "not {input_pull_dir}") self.long_press_interval = long_press_interval self.long_press_action = long_press_action if self.long_press_interval is not None and \ self.long_press_action is None: raise ValueError("long_press_action required but not found!") # in msec, how fast the notification light should pulse when # the schedule is set. First number is on time, second is off time self.schedule_notification_interval = (50, 1500) self.gpio_setup() self.loop = asyncio.get_event_loop() self.loop_running = True if self.input_pin: self.task = self.loop.create_task(self.gpio_timer()) FauxmoGpioPlugin._num_instances += 1 super().__init__(name=name, port=port) logger.info(f"Fauxmo GPIO device {self.name} initialized")
def handle_action(self, msg: str) -> None: """Execute `on`, `off`, or `get_state` method of plugin. Args: msg: Body of the Echo's HTTP request to trigger an action """ logger.debug(f"Handling action for plugin type {self.plugin}") soap_format = ( "<s:Envelope " 'xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ' 's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' "<s:Body>" "<u:{action}{action_type}Response " 'xmlns:u="urn:Belkin:service:basicevent:1">' "<{action_type}>{return_val}</{action_type}>" "</u:{action}{action_type}Response>" "</s:Body>" "</s:Envelope>").format command_format = ( 'SOAPACTION: "urn:Belkin:service:basicevent:1#{}"').format soap_message: str = None action: str = None action_type: str = None return_val: str = None success: bool = False if command_format("GetBinaryState").casefold() in msg.casefold(): logger.info(f"Attempting to get state for {self.plugin.name}") action = "Get" action_type = "BinaryState" state = self.plugin.get_state().casefold() logger.info(f"{self.plugin.name} state: {state}") if state in ["off", "on"]: success = True return_val = str(int(state.lower() == "on")) elif command_format("SetBinaryState").casefold() in msg.casefold(): action = "Set" action_type = "BinaryState" if "<BinaryState>0</BinaryState>" in msg: logger.info(f"Attempting to turn off {self.plugin.name}") return_val = "0" success = self.plugin.off() elif "<BinaryState>1</BinaryState>" in msg: logger.info(f"Attempting to turn on {self.plugin.name}") return_val = "1" success = self.plugin.on() else: logger.warning(f"Unrecognized request:\n{msg}") elif command_format("GetFriendlyName").casefold() in msg.casefold(): action = "Get" action_type = "FriendlyName" return_val = self.plugin.name success = True logger.info(f"{self.plugin.name} returning friendly name") if success: soap_message = soap_format(action=action, action_type=action_type, return_val=return_val) response = self.add_http_headers(soap_message) logger.debug(response) self.transport.write(response.encode()) else: errmsg = ( f"Unable to complete command for {self.plugin.name}:\n{msg}") logger.warning(errmsg) self.transport.close()
Provides console_script via argparse. """ import argparse import sys from fauxmo import __version__, logger from fauxmo.fauxmo import main try: import uvloop except ImportError: pass else: logger.info("Using uvloop") uvloop.install() def cli() -> None: """Parse command line options, provide entry point for console scripts.""" arguments = sys.argv[1:] parser = argparse.ArgumentParser( description="Emulate Belkin Wemo devices for use with Amaazon Echo" ) parser.add_argument( "-v", "--verbose", help="increase verbosity (may repeat up to -vvv)", action="count", default=0,