- Added support for device scanning in switch.wemo plugin
- Added generic interface for workers to run jobs in parallel
This commit is contained in:
parent
5481990834
commit
6082eb62d5
4 changed files with 301 additions and 55 deletions
|
@ -1,18 +1,10 @@
|
||||||
import enum
|
import ipaddress
|
||||||
import re
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
from xml.dom.minidom import parseString
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from platypush.plugins import action
|
from platypush.plugins import action
|
||||||
from platypush.plugins.switch import SwitchPlugin
|
from platypush.plugins.switch import SwitchPlugin
|
||||||
|
from platypush.utils.workers import Workers
|
||||||
|
from .lib import WemoRunner
|
||||||
class SwitchAction(enum.Enum):
|
from .scanner import Scanner
|
||||||
GET_STATE = 'GetBinaryState'
|
|
||||||
SET_STATE = 'SetBinaryState'
|
|
||||||
GET_NAME = 'GetFriendlyName'
|
|
||||||
|
|
||||||
|
|
||||||
class SwitchWemoPlugin(SwitchPlugin):
|
class SwitchWemoPlugin(SwitchPlugin):
|
||||||
|
@ -27,19 +19,29 @@ class SwitchWemoPlugin(SwitchPlugin):
|
||||||
|
|
||||||
_default_port = 49153
|
_default_port = 49153
|
||||||
|
|
||||||
def __init__(self, devices=None, **kwargs):
|
def __init__(self, devices=None, netmask: str = None, port: int = _default_port, **kwargs):
|
||||||
"""
|
"""
|
||||||
:param devices: List of IP addresses or name->address map containing the WeMo Switch devices to control.
|
:param devices: List of IP addresses or name->address map containing the WeMo Switch devices to control.
|
||||||
This plugin previously used ouimeaux for auto-discovery but it's been dropped because
|
This plugin previously used ouimeaux for auto-discovery but it's been dropped because
|
||||||
1. too slow 2. too heavy 3. auto-discovery failed too often.
|
1. too slow 2. too heavy 3. auto-discovery failed too often.
|
||||||
:type devices: list or dict
|
:type devices: list or dict
|
||||||
|
|
||||||
|
:param netmask: Alternatively to a list of static IP->name pairs, you can specify the network mask where
|
||||||
|
the devices should be scanned (e.g. '192.168.1.0/24')
|
||||||
|
|
||||||
|
:param port: Port where the WeMo devices are expected to expose the RPC/XML over HTTP service (default: 49153)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._port = self._default_port
|
self.port = port
|
||||||
|
self.netmask = netmask
|
||||||
|
self._devices = {}
|
||||||
|
self._init_devices(devices)
|
||||||
|
|
||||||
|
def _init_devices(self, devices):
|
||||||
if devices:
|
if devices:
|
||||||
self._devices = devices if isinstance(devices, dict) else \
|
self._devices.update(devices if isinstance(devices, dict) else
|
||||||
{addr: addr for addr in devices}
|
{addr: addr for addr in devices})
|
||||||
else:
|
else:
|
||||||
self._devices = {}
|
self._devices = {}
|
||||||
|
|
||||||
|
@ -71,51 +73,25 @@ class SwitchWemoPlugin(SwitchPlugin):
|
||||||
for device in self._devices.values()
|
for device in self._devices.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
# noinspection PyShadowingNames
|
def _get_address(self, device: str) -> str:
|
||||||
def _exec(self, device: str, action: SwitchAction, port: int = _default_port, value=None):
|
|
||||||
if device not in self._addresses:
|
if device not in self._addresses:
|
||||||
try:
|
try:
|
||||||
device = self._devices[device]
|
return self._devices[device]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
state_name = action.value[3:]
|
return device
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
'http://{}:{}/upnp/control/basicevent1'.format(device, port),
|
|
||||||
headers={
|
|
||||||
'User-Agent': '',
|
|
||||||
'Accept': '',
|
|
||||||
'Content-Type': 'text/xml; charset="utf-8"',
|
|
||||||
'SOAPACTION': '\"urn:Belkin:service:basicevent:1#{}\"'.format(action.value),
|
|
||||||
},
|
|
||||||
data=re.sub('\s+', ' ', textwrap.dedent(
|
|
||||||
'''
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
|
||||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
||||||
<s:Body>
|
|
||||||
<u:{action} xmlns:u="urn:Belkin:service:basicevent:1">
|
|
||||||
<{state}>{value}</{state}>
|
|
||||||
</u:{action}
|
|
||||||
></s:Body>
|
|
||||||
</s:Envelope>
|
|
||||||
'''.format(action=action.value, state=state_name,
|
|
||||||
value=value if value is not None else ''))))
|
|
||||||
|
|
||||||
dom = parseString(response.text)
|
|
||||||
return dom.getElementsByTagName(state_name).item(0).firstChild.data
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def status(self, device=None, *args, **kwargs):
|
def status(self, device: str = None, *args, **kwargs):
|
||||||
devices = {device: device} if device else self._devices.copy()
|
devices = {device: device} if device else self._devices.copy()
|
||||||
|
|
||||||
ret = [
|
ret = [
|
||||||
{
|
{
|
||||||
'id': addr,
|
'id': addr,
|
||||||
'ip': addr,
|
'ip': addr,
|
||||||
'name': name if name != addr else self.get_name(addr).output,
|
'name': name if name != addr else WemoRunner.get_name(addr),
|
||||||
'on': self.get_state(addr).output,
|
'on': WemoRunner.get_state(addr),
|
||||||
}
|
}
|
||||||
for (name, addr) in devices.items()
|
for (name, addr) in devices.items()
|
||||||
]
|
]
|
||||||
|
@ -129,7 +105,8 @@ class SwitchWemoPlugin(SwitchPlugin):
|
||||||
|
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
self._exec(device=device, action=SwitchAction.SET_STATE, value=1)
|
device = self._get_address(device)
|
||||||
|
WemoRunner.on(device)
|
||||||
return self.status(device)
|
return self.status(device)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -139,7 +116,8 @@ class SwitchWemoPlugin(SwitchPlugin):
|
||||||
|
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
self._exec(device=device, action=SwitchAction.SET_STATE, value=0)
|
device = self._get_address(device)
|
||||||
|
WemoRunner.off(device)
|
||||||
return self.status(device)
|
return self.status(device)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -149,8 +127,9 @@ class SwitchWemoPlugin(SwitchPlugin):
|
||||||
|
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
state = self.get_state(device).output
|
device = self._get_address(device)
|
||||||
return self.on(device) if not state else self.off(device)
|
WemoRunner.toggle(device)
|
||||||
|
return self.status(device)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_state(self, device: str):
|
def get_state(self, device: str):
|
||||||
|
@ -159,8 +138,8 @@ class SwitchWemoPlugin(SwitchPlugin):
|
||||||
|
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
state = self._exec(device=device, action=SwitchAction.GET_STATE)
|
device = self._get_address(device)
|
||||||
return bool(int(state))
|
return WemoRunner.get_state(device)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_name(self, device: str):
|
def get_name(self, device: str):
|
||||||
|
@ -169,7 +148,26 @@ class SwitchWemoPlugin(SwitchPlugin):
|
||||||
|
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
return self._exec(device=device, action=SwitchAction.GET_NAME)
|
device = self._get_address(device)
|
||||||
|
return WemoRunner.get_name(device)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def scan(self, netmask: str = None):
|
||||||
|
netmask = netmask or self.netmask
|
||||||
|
assert netmask, 'Scan not supported: No netmask specified'
|
||||||
|
|
||||||
|
workers = Workers(10, Scanner, port=self.port)
|
||||||
|
with workers:
|
||||||
|
for addr in ipaddress.IPv4Network(netmask):
|
||||||
|
workers.put(addr.exploded)
|
||||||
|
|
||||||
|
devices = {
|
||||||
|
dev.name: dev.addr
|
||||||
|
for dev in workers.responses
|
||||||
|
}
|
||||||
|
|
||||||
|
self._init_devices(devices)
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
71
platypush/plugins/switch/wemo/lib.py
Normal file
71
platypush/plugins/switch/wemo/lib.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import enum
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from xml.dom.minidom import parseString
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchAction(enum.Enum):
|
||||||
|
GET_STATE = 'GetBinaryState'
|
||||||
|
SET_STATE = 'SetBinaryState'
|
||||||
|
GET_NAME = 'GetFriendlyName'
|
||||||
|
|
||||||
|
|
||||||
|
class WemoRunner:
|
||||||
|
default_port = 49153
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def exec(device: str, action: SwitchAction, port: int = default_port, value=None):
|
||||||
|
state_name = action.value[3:]
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
'http://{}:{}/upnp/control/basicevent1'.format(device, port),
|
||||||
|
headers={
|
||||||
|
'User-Agent': '',
|
||||||
|
'Accept': '',
|
||||||
|
'Content-Type': 'text/xml; charset="utf-8"',
|
||||||
|
'SOAPACTION': '\"urn:Belkin:service:basicevent:1#{}\"'.format(action.value),
|
||||||
|
},
|
||||||
|
data=re.sub('\s+', ' ', textwrap.dedent(
|
||||||
|
'''
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:{action} xmlns:u="urn:Belkin:service:basicevent:1">
|
||||||
|
<{state}>{value}</{state}>
|
||||||
|
</u:{action}
|
||||||
|
></s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
'''.format(action=action.value, state=state_name,
|
||||||
|
value=value if value is not None else ''))))
|
||||||
|
|
||||||
|
dom = parseString(response.text)
|
||||||
|
return dom.getElementsByTagName(state_name).item(0).firstChild.data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_state(cls, device: str) -> bool:
|
||||||
|
state = cls.exec(device=device, action=SwitchAction.GET_STATE)
|
||||||
|
return bool(int(state))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_name(cls, device: str) -> str:
|
||||||
|
name = cls.exec(device=device, action=SwitchAction.GET_NAME)
|
||||||
|
return name
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def on(cls, device):
|
||||||
|
cls.exec(device=device, action=SwitchAction.SET_STATE, value=1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def off(cls, device):
|
||||||
|
cls.exec(device=device, action=SwitchAction.SET_STATE, value=0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def toggle(cls, device):
|
||||||
|
state = cls.get_state(device)
|
||||||
|
cls.exec(device=device, action=SwitchAction.SET_STATE, value=int(not state))
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
37
platypush/plugins/switch/wemo/scanner.py
Normal file
37
platypush/plugins/switch/wemo/scanner.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import multiprocessing
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from platypush.utils.workers import Worker
|
||||||
|
from .lib import WemoRunner
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResult:
|
||||||
|
def __init__(self, addr: str, name: str, on: bool):
|
||||||
|
self.addr = addr
|
||||||
|
self.name = name
|
||||||
|
self.on = on
|
||||||
|
|
||||||
|
|
||||||
|
class Scanner(Worker):
|
||||||
|
timeout = 1.5
|
||||||
|
|
||||||
|
def __init__(self, request_queue: multiprocessing.Queue, response_queue: multiprocessing.Queue,
|
||||||
|
port: int = WemoRunner.default_port):
|
||||||
|
super().__init__(request_queue, response_queue)
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
def process(self, addr: str) -> Optional[ScanResult]:
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
sock.connect((addr, self.port))
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
return ScanResult(addr=addr, name=WemoRunner.get_name(addr), on=WemoRunner.get_state(addr))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
140
platypush/utils/workers.py
Normal file
140
platypush/utils/workers.py
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
|
||||||
|
class EndOfStream:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Worker(ABC, multiprocessing.Process):
|
||||||
|
"""
|
||||||
|
Generic class for worker processes, used to split the execution of an action over multiple
|
||||||
|
parallel instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request_queue: multiprocessing.Queue, response_queue=multiprocessing.Queue):
|
||||||
|
"""
|
||||||
|
:param request_queue: The worker will listen for messages to process over this queue
|
||||||
|
:param response_queue: The worker will return responses over this queue
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.request_queue = request_queue
|
||||||
|
self.response_queue = response_queue
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""
|
||||||
|
The worker will run until it receives a :class:`EndOfStream` message on the queue.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
msg = self.request_queue.get()
|
||||||
|
if isinstance(msg, EndOfStream):
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret = self.process(msg)
|
||||||
|
except Exception as e:
|
||||||
|
ret = e
|
||||||
|
|
||||||
|
if ret:
|
||||||
|
# noinspection PyArgumentList,PyCallByClass
|
||||||
|
self.response_queue.put(ret)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(self, msg):
|
||||||
|
"""
|
||||||
|
This method must be implemented by the derived classes.
|
||||||
|
It will take as argument a message received over the `request_queue` and will return a value that will be
|
||||||
|
processed by the consumer or None.
|
||||||
|
|
||||||
|
If this function raises an exception then the exception will be pushed to the response queue and can be
|
||||||
|
handled by the consumer.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError('Must be implemented by a derived class')
|
||||||
|
|
||||||
|
def end_stream(self) -> None:
|
||||||
|
"""
|
||||||
|
This method will be called when we have no more messages to send to the worker. A special
|
||||||
|
`EndOfStream` object will be sent on the `request_queue`.
|
||||||
|
"""
|
||||||
|
self.request_queue.put(EndOfStream())
|
||||||
|
|
||||||
|
|
||||||
|
class Workers:
|
||||||
|
"""
|
||||||
|
Model for a pool of workers. Syntax:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Squarer(Worker):
|
||||||
|
def process(self, n):
|
||||||
|
return n * n
|
||||||
|
|
||||||
|
workers = Workers(5, Squarer) # Allocate 5 workers of type Squarer
|
||||||
|
with workers:
|
||||||
|
for n in range(100)):
|
||||||
|
workers.put(n)
|
||||||
|
|
||||||
|
print(workers.responses)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, n_workers: int, worker_type: Type[Worker], *args, **kwargs):
|
||||||
|
"""
|
||||||
|
:param n_workers: Number of workers
|
||||||
|
:param worker_type: Type of the workers that will be instantiated. Must be a subclass of :class:`Worker`.
|
||||||
|
:param args: Extra args to pass to the `worker_type` constructor
|
||||||
|
:param kwargs: Extra kwargs to pass to the `worker_type` constructor
|
||||||
|
"""
|
||||||
|
self.request_queue = multiprocessing.Queue()
|
||||||
|
self.response_queue = multiprocessing.Queue()
|
||||||
|
# noinspection PyArgumentList
|
||||||
|
self._workers = [worker_type(self.request_queue, self.response_queue, *args, **kwargs)
|
||||||
|
for _ in range(n_workers)]
|
||||||
|
self.responses: list = []
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
for wrk in self._workers:
|
||||||
|
wrk.start()
|
||||||
|
|
||||||
|
def put(self, msg) -> None:
|
||||||
|
"""
|
||||||
|
Put a message on the `request_queue`
|
||||||
|
"""
|
||||||
|
self.request_queue.put(msg)
|
||||||
|
|
||||||
|
def wait(self) -> list:
|
||||||
|
"""
|
||||||
|
Wait for the termination of all the workers
|
||||||
|
|
||||||
|
:ret: A list containing the processed responses
|
||||||
|
"""
|
||||||
|
while self._workers:
|
||||||
|
for i, wrk in enumerate(self._workers):
|
||||||
|
if not self._workers[i].is_alive():
|
||||||
|
self._workers.pop(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.responses = []
|
||||||
|
while not self.response_queue.empty():
|
||||||
|
self.responses.append(self.response_queue.get())
|
||||||
|
return self.responses
|
||||||
|
|
||||||
|
def end_stream(self):
|
||||||
|
"""
|
||||||
|
Mark the termination of the stream by sending an :class:`EndOfStream` message on the `request_queue` for
|
||||||
|
each of the running workers.
|
||||||
|
"""
|
||||||
|
for wrk in self._workers:
|
||||||
|
wrk.end_stream()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.end_stream()
|
||||||
|
self.wait()
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
Loading…
Reference in a new issue