From e3d44b56dd9235be787c4201606998396c096c3b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 5 Dec 2019 00:37:55 +0100 Subject: [PATCH] Added Join plugin to control remote Android devices --- .../plugins/mobile.join.__init__.rst | 6 + docs/source/plugins.rst | 1 + platypush/plugins/mobile/join/__init__.py | 425 ++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 docs/source/platypush/plugins/mobile.join.__init__.rst create mode 100644 platypush/plugins/mobile/join/__init__.py diff --git a/docs/source/platypush/plugins/mobile.join.__init__.rst b/docs/source/platypush/plugins/mobile.join.__init__.rst new file mode 100644 index 000000000..5375fe833 --- /dev/null +++ b/docs/source/platypush/plugins/mobile.join.__init__.rst @@ -0,0 +1,6 @@ +``platypush.plugins.mobile.join.__init__`` +========================================== + +.. automodule:: platypush.plugins.mobile.join.__init__ + :members: + diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 302eb9699..514c25def 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -58,6 +58,7 @@ Plugins platypush/plugins/media.webtorrent.rst platypush/plugins/midi.rst platypush/plugins/ml.cv.rst + platypush/plugins/mobile.join.rst platypush/plugins/mqtt.rst platypush/plugins/music.mpd.rst platypush/plugins/music.snapcast.rst diff --git a/platypush/plugins/mobile/join/__init__.py b/platypush/plugins/mobile/join/__init__.py new file mode 100644 index 000000000..b382b3eb9 --- /dev/null +++ b/platypush/plugins/mobile/join/__init__.py @@ -0,0 +1,425 @@ +import enum +import requests +import threading + +from platypush.plugins import Plugin, action + + +class InterruptionFilterPolicy(enum.Enum): + ALLOW_ALL = 1 + PRIORITY_ONLY = 2 + BLOCK_ALL = 3 + ALARM_ONLY = 4 + + +class MobileJoinPlugin(Plugin): + """ + Control mobile devices and other devices linked to Join (https://joaoapps.com/join/api/). + It requires you to have either the Join app installed on an Android device or the Join + browser extension installed. + + The ``device`` parameter in the actions can be: + + - A device ID or name + - A list or comma-separated list of device IDs/names + - A group name or a list of group names + + Supported groups: + + - group.all + - group.android + - group.windows10 + - group.phone + - group.tablet + - group.pc + + """ + + _base_url = 'https://joinjoaomgcd.appspot.com/_ah/api' + _push_url = '{}/messaging/v1/sendPush'.format(_base_url) + _devices_url = '{}/registration/v1/listDevices'.format(_base_url) + _groups = { + 'group.all', + 'group.android', + 'group.windows10', + 'group.phone', + 'group.tablet', + 'group.pc', + } + + def __init__(self, api_key: str, **kwargs): + """ + :param api_key: Join API key. Get your at https://joinjoaomgcd.appspot.com/. + """ + super().__init__(**kwargs) + + self._api_key = api_key + self._devices = {} + self._devices_by_id = {} + self._devices_by_name = {} + self._devices_lock = threading.RLock() + + def _send_request(self, url, device=None, params: dict = None, **kwargs): + if not params: + params = {} + + params['apikey'] = self._api_key + if device: + params['deviceIds'] = self._get_device_ids(device) + + response = requests.get(url, params=params, **kwargs) + if not response.ok: + response.raise_for_status() + + response = response.json() + if response.get('userAuthError'): + raise PermissionError('Invalid api_key provided') + + del response['userAuthError'] + return response + + def _init_devices(self): + with self._devices_lock: + if not self._devices: + self._devices = self.get_devices().output + self._devices_by_id = {dev['id']: dev for dev in self._devices} + self._devices_by_name = {dev['name']: dev for dev in self._devices} + + def _get_device_ids(self, device): + devices = [dev.strip() for dev in device.split(',')] \ + if isinstance(device, str) else device + assert isinstance(devices, list) + + has_unknown_devices = True + cache_refreshed = False + device_ids = [] + + while has_unknown_devices: + has_unknown_devices = False + + for dev in devices: + if dev in self._devices_by_id: + device_ids.append(self._devices_by_id[dev]['id']) + elif dev in self._devices_by_name: + device_ids.append(self._devices_by_name[dev]['id']) + elif dev in self._groups: + device_ids.append(dev) + else: + has_unknown_devices = True + + if has_unknown_devices: + if cache_refreshed: + raise KeyError('No such device: {}'.format(device)) + + self._init_devices() + cache_refreshed = True + + return device_ids + + @action + def push(self, device, text=None, url=None, file=None): + """ + Push a URL or file to one or more devices + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param text: Optional description text for the URL or file + :param url: URL to be pushed + :param file: A publicly accessible URL of a file. You can also send the url of a file on your personal + Google Drive + """ + + assert url or file + params = {} + + if text: + params['text'] = text + if url: + params['url'] = url + if file: + params['file'] = file + + return self._send_request(self._push_url, device=device, params=params) + + @action + def set_clipboard(self, device, text): + """ + Write to the clipboard of a device + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param text: Text to be set + """ + return self._send_request(self._push_url, device=device, params={'clipboard': text}) + + @action + def send_sms(self, device, text: str, number: str = None, contact_name: str = None): + """ + Send an sms through a mobile device connected to Join + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param text: Text to be sent + :param number: Phone number + :param contact_name: Alternatively to the phone number, you can specify a contact name + """ + + assert (number or contact_name) and not (number and contact_name) + params = {'smstext': text} + + if number: + params['smsnumber'] = number + else: + params['smscontactname'] = contact_name + + return self._send_request(self._push_url, device=device, params=params) + + @action + def send_mms(self, device, file: str = None, text: str = None, subject: str = '', + number: str = None, contact_name: str = None, urgent: bool = False): + """ + Send an MMS through a mobile device connected to Join + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param text: Text to be sent + :param number: Phone number + :param contact_name: Alternatively to the phone number, you can specify a contact name + :param file: File attached to the message. Must be a local (to the phone) file or a publicly accessible URL + :param subject: MMS subject + :param urgent: Set to True if this is an urgent MMS. This will make the sent message be an MMS instead of an SMS + """ + + assert (number or contact_name) and not (number and contact_name) + params = { + 'mmssubject': subject, + 'mmsurgent': int(urgent), + } + + if number: + params['smsnumber'] = number + else: + params['smscontactname'] = contact_name + if file: + params['mmsfile'] = file + if text: + params['smstext'] = text + + return self._send_request(self._push_url, device=device, params=params) + + @action + def call_number(self, device, number: str): + """ + Call a phone number through a mobile device connected to Join + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param number: Phone number + """ + return self._send_request(self._push_url, device=device, params={'callnumber': number}) + + @action + def set_wallpaper(self, device, wallpaper: str): + """ + Set the wallpaper on a device connected to Join + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param wallpaper: A publicly accessible URL of an image file. Will set the wallpaper on the receiving device + """ + return self._send_request(self._push_url, device=device, params={'wallpaper': wallpaper}) + + @action + def set_lock_wallpaper(self, device, wallpaper: str): + """ + Set the lock wallpaper on a device connected to Join + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param wallpaper: A publicly accessible URL of an image file. Will set the lockscreen wallpaper on the receiving + device if the device has Android 7 or above + """ + return self._send_request(self._push_url, device=device, params={'wallpaper': wallpaper}) + + @action + def find(self, device): + """ + Make a device ring loudly + :param device: Device ID or name, or list of device IDs/names, or group name(s) + """ + return self._send_request(self._push_url, device=device, params={'find': True}) + + @action + def set_media_volume(self, device, volume: float): + """ + Set media volume + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param volume: Volume + """ + return self._send_request(self._push_url, device=device, params={'mediaVolume': volume}) + + @action + def set_ring_volume(self, device, volume: float): + """ + Set ring volume + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param volume: Volume + """ + return self._send_request(self._push_url, device=device, params={'ringVolume': volume}) + + @action + def set_alarm_volume(self, device, volume: float): + """ + Set alarm volume + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param volume: Volume + """ + return self._send_request(self._push_url, device=device, params={'alarmVolume': volume}) + + @action + def set_interruption_filter(self, device, policy: str): + """ + Set interruption filter on one or more devices + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param policy: Possible values: + + - 'allow_all': Allow all notifications + - 'priority_only': Allow only priority notifications and calls + - 'alarm_only': Allow only alarm-related interruptions + - 'block_all': Do not allow any interruptions + + """ + + try: + policy = getattr(InterruptionFilterPolicy, policy.upper()) + except AttributeError: + raise AttributeError('Invalid policy: {}. Supported values: {}'.format( + policy, [i.name for i in InterruptionFilterPolicy] + )) + + return self._send_request(self._push_url, device=device, params={'interruptionFilter': policy}) + + @action + def say(self, device, text: str, language: str = None): + """ + Say some text through a device's TTS engine + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param text: Text to say + :param language: Language code + """ + params = {'say': text} + if language: + params['language'] = language + + return self._send_request(self._push_url, device=device, params=params) + + @action + def launch_app(self, device, name: str = None, package: str = None): + """ + Launch an app on a device + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param name: Application name + :param package: Alternatively to the application name, you can also specify the application + package name. You can check the package name for an app by going to its Google Play page and checking + the end of the URL. Example: for YouTube this is the URL + (https://play.google.com/store/apps/details?id=com.google.android.youtube) and this is the package name + (com.google.android.youtube) + """ + + assert (name or package) and not (name and package) + + params = {} + if name: + params['app'] = name + else: + params['appPackage'] = package + + return self._send_request(self._push_url, device=device, params=params) + + @action + def send_notification(self, device, title: str = None, text: str = None, url: str = None, file: str = None, + icon: str = None, small_icon: str = None, priority: int = 2, vibration_pattern=None, + dismiss_on_touch: bool = False, image: str = None, group: str = None, + sound: str = None, actions=None): + """ + Send a notification to a device + + :param device: Device ID or name, or list of device IDs/names, or group name(s) + :param title: Notification title + :param text: Notification text + :param url: URL to be opened on touch + :param file: A publicly accessible URL of a file that will be opened or downloaded on touch. You can also + send the url of a file on your personal Google Drive. + :param icon: If a notification is created on the receiving device and this is set, then it'll be used as + the notification’s icon. If this image has transparency, it’ll also be used as the status bar icon is + smallicon is not set. It’ll also be used to tint the notification with its dominating color + :param small_icon: If a notification is created on the receiving device and this is set, then it’ll be used as + the notification’s status bar icon + :param priority: Control how your notification is displayed: lower priority notifications are usually + displayed lower in the notification list. Values from -2 (lowest priority) to 2 (highest priority). + Default is 2. + :param vibration_pattern: If the notification is received on an Android device, the vibration pattern in this + field will change the way the device vibrates with it. You can easily create a pattern by going + `here `_ and generating the pattern in + the Vibration Pattern field + :type vibration_pattern: str (comma-separated float values) or list[float] + :param dismiss_on_touch: If set the notification will be dismissed when touched (default: False) + :param image: Publicly available URL for an image to show up in the notification + :param group: Unique ID to group your notifications with + :param sound: Publicly available URL for a sound to play with the notification + :param actions: Set notification buttons with customized behaviour. This parameter is a list of Join actions + configured on the target device that will be mapped to notification input elements. + More info `here `_ + """ + + params = {'dismissOnTouch': dismiss_on_touch} + + if title: + params['title'] = title + if text: + params['text'] = text + if url: + params['url'] = url + if file: + params['file'] = file + if icon: + params['icon'] = icon + if small_icon: + params['smallIcon'] = small_icon + if priority: + params['priority'] = priority + if vibration_pattern: + params['vibrationPattern'] = [i for i in vibration_pattern.split(',') if len(i)] \ + if isinstance(vibration_pattern, str) else vibration_pattern + assert isinstance(params['vibrationPattern'], list) + if image: + params['image'] = image + if group: + params['group'] = group + if sound: + params['sound'] = sound + if actions: + actions = [a.strip() for a in actions.split('|||' if '|||' in actions else ',') if len(a.strip())] \ + if isinstance(actions, str) else actions + assert isinstance(actions, list) and len(actions) > 0 + params['actions'] = '|||'.join(actions) + + return self._send_request(self._push_url, device=device, params=params) + + @action + def get_devices(self): + """ + :return: List of connected devices, each containing 'id', 'name', 'user' and 'has_tasker' attributes + """ + + return [ + { + 'id': dev['deviceId'], + 'name': dev['deviceName'], + 'user': dev['userAccount'], + 'has_tasker': dev['hasTasker'], + } + for dev in self._send_request(self._devices_url)['records'] + ] + + +# vim:sw=4:ts=4:et: