diff --git a/.gitignore b/.gitignore index c08d6c1f..0ab8d748 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ platypush/backend/http/static/css/*/.sass-cache/ .vscode platypush/backend/http/static/js/lib/vue.js platypush/notebooks +platypush/requests diff --git a/docs/source/backends.rst b/docs/source/backends.rst index eb26189d..44b63afe 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -29,6 +29,7 @@ Backends platypush/backend/joystick.rst platypush/backend/kafka.rst platypush/backend/light.hue.rst + platypush/backend/linode.rst platypush/backend/local.rst platypush/backend/midi.rst platypush/backend/mqtt.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index e372581f..dc60d662 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -245,6 +245,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'pvporcupine ', 'pvcheetah', 'pyotp', + 'linode_api4', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/docs/source/events.rst b/docs/source/events.rst index 708ab392..94bba0c4 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -29,6 +29,7 @@ Events platypush/events/joystick.rst platypush/events/kafka.rst platypush/events/light.rst + platypush/events/linode.rst platypush/events/media.rst platypush/events/midi.rst platypush/events/mqtt.rst diff --git a/docs/source/platypush/backend/linode.rst b/docs/source/platypush/backend/linode.rst new file mode 100644 index 00000000..f11baffb --- /dev/null +++ b/docs/source/platypush/backend/linode.rst @@ -0,0 +1,5 @@ +``platypush.backend.linode`` +============================ + +.. automodule:: platypush.backend.linode + :members: diff --git a/docs/source/platypush/events/linode.rst b/docs/source/platypush/events/linode.rst new file mode 100644 index 00000000..7c5c878c --- /dev/null +++ b/docs/source/platypush/events/linode.rst @@ -0,0 +1,5 @@ +``platypush.message.event.linode`` +================================== + +.. automodule:: platypush.message.event.linode + :members: diff --git a/docs/source/platypush/plugins/linode.rst b/docs/source/platypush/plugins/linode.rst new file mode 100644 index 00000000..c0e322eb --- /dev/null +++ b/docs/source/platypush/plugins/linode.rst @@ -0,0 +1,5 @@ +``platypush.plugins.linode`` +============================ + +.. automodule:: platypush.plugins.linode + :members: diff --git a/docs/source/platypush/responses/linode.rst b/docs/source/platypush/responses/linode.rst new file mode 100644 index 00000000..805fcc89 --- /dev/null +++ b/docs/source/platypush/responses/linode.rst @@ -0,0 +1,5 @@ +``platypush.message.response.linode`` +===================================== + +.. automodule:: platypush.message.response.linode + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 05e43934..c0cbf035 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -61,6 +61,7 @@ Plugins platypush/plugins/lastfm.rst platypush/plugins/light.rst platypush/plugins/light.hue.rst + platypush/plugins/linode.rst platypush/plugins/logger.rst platypush/plugins/media.rst platypush/plugins/media.chromecast.rst diff --git a/docs/source/responses.rst b/docs/source/responses.rst index bcdce47a..8e8ab6dd 100644 --- a/docs/source/responses.rst +++ b/docs/source/responses.rst @@ -11,6 +11,7 @@ Responses platypush/responses/camera.android.rst platypush/responses/chat.telegram.rst platypush/responses/google.drive.rst + platypush/responses/linode.rst platypush/responses/pihole.rst platypush/responses/ping.rst platypush/responses/printer.cups.rst diff --git a/platypush/backend/bluetooth/scanner/__init__.py b/platypush/backend/bluetooth/scanner/__init__.py index 77821abb..a9eed886 100644 --- a/platypush/backend/bluetooth/scanner/__init__.py +++ b/platypush/backend/bluetooth/scanner/__init__.py @@ -20,14 +20,14 @@ class BluetoothScannerBackend(SensorBackend): """ - def __init__(self, device_id: Optional[int] = None, scan_interval: int = 10, **kwargs): + def __init__(self, device_id: Optional[int] = None, scan_duration: int = 10, **kwargs): """ :param device_id: Bluetooth adapter ID to use (default configured on the ``bluetooth`` plugin if None). - :param scan_interval: How long the scan should run (default: 10 seconds). + :param scan_duration: How long the scan should run (default: 10 seconds). """ super().__init__(plugin='bluetooth', plugin_args={ 'device_id': device_id, - 'duration': scan_interval, + 'duration': scan_duration, }, **kwargs) self._last_seen_devices = {} diff --git a/platypush/backend/bluetooth/scanner/ble.py b/platypush/backend/bluetooth/scanner/ble.py index 3a66e41f..30e1d625 100644 --- a/platypush/backend/bluetooth/scanner/ble.py +++ b/platypush/backend/bluetooth/scanner/ble.py @@ -19,14 +19,14 @@ class BluetoothBleScannerBackend(BluetoothScannerBackend): """ - def __init__(self, interface: Optional[int] = None, scan_interval: int = 10, **kwargs): + def __init__(self, interface: Optional[int] = None, scan_duration: int = 10, **kwargs): """ :param interface: Bluetooth adapter name to use (default configured on the ``bluetooth.ble`` plugin if None). - :param scan_interval: How long the scan should run (default: 10 seconds). + :param scan_duration: How long the scan should run (default: 10 seconds). """ super().__init__(plugin='bluetooth.ble', plugin_args={ 'interface': interface, - 'duration': scan_interval, + 'duration': scan_duration, }, **kwargs) diff --git a/platypush/backend/linode.py b/platypush/backend/linode.py new file mode 100644 index 00000000..ec4ad355 --- /dev/null +++ b/platypush/backend/linode.py @@ -0,0 +1,46 @@ +from typing import Dict, Optional, List + +from platypush.backend.sensor import SensorBackend +from platypush.message.event.linode import LinodeInstanceStatusChanged + + +class LinodeBackend(SensorBackend): + """ + This backend monitors the state of one or more Linode instances. + + Triggers: + + * :class:`platypush.message.event.linode.LinodeInstanceStatusChanged` when the status of an instance changes. + + Requires: + + * The :class:`platypush.plugins.linode.LinodePlugin` plugin configured. + + """ + + def __init__(self, instances: Optional[List[str]] = None, poll_seconds: float = 30.0, **kwargs): + """ + :param instances: List of instances to monitor, by label (default: monitor all the instances). + """ + super().__init__(plugin='linode', poll_seconds=poll_seconds, **kwargs) + self.instances = set(instances or []) + + def process_data(self, data: Dict[str, dict], *args, **kwargs): + instances = data['instances'] + old_instances = (self.data or {}).get('instances', {}) + + if self.instances: + instances = {label: instances[label] for label in self.instances if label in instances} + + if not instances: + return + + for label, instance in instances.items(): + old_instance = old_instances.get(label, {}) + if 'status' in old_instance and old_instance['status'] != instance['status']: + self.bus.post(LinodeInstanceStatusChanged(instance=label, + status=instance['status'], + old_status=old_instance['status'])) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/sensor/__init__.py b/platypush/backend/sensor/__init__.py index 966176bd..2cb3757a 100644 --- a/platypush/backend/sensor/__init__.py +++ b/platypush/backend/sensor/__init__.py @@ -175,7 +175,7 @@ class SensorBackend(Backend): def process_data(self, data, new_data): if new_data: - self.bus.post(SensorDataChangeEvent(data=data, source=self.plugin or self.__class__.__name__)) + self.bus.post(SensorDataChangeEvent(data=new_data, source=self.plugin or self.__class__.__name__)) def run(self): super().run() diff --git a/platypush/message/event/linode.py b/platypush/message/event/linode.py new file mode 100644 index 00000000..f40cd66d --- /dev/null +++ b/platypush/message/event/linode.py @@ -0,0 +1,18 @@ +from typing import Optional + +from platypush.message.event import Event + + +class LinodeEvent(Event): + pass + + +class LinodeInstanceStatusChanged(LinodeEvent): + """ + Event triggered when the status of a Linode instance changes. + """ + def __init__(self, instance: str, status: str, old_status: Optional[str] = None, *args, **kwargs): + super().__init__(*args, instance=instance, status=status, old_status=old_status, **kwargs) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/response/linode.py b/platypush/message/response/linode.py new file mode 100644 index 00000000..40db6b45 --- /dev/null +++ b/platypush/message/response/linode.py @@ -0,0 +1,164 @@ +import datetime +from typing import List + +from linode_api4.objects.linode import Instance, Config, Disk, Backup, Image, Kernel, Type + +from platypush.message import Mapping +from platypush.message.response import Response + + +class LinodeResponse(Response): + pass + + +class LinodeConfigModel(Mapping): + def __init__(self, config: Config): + super().__init__() + self.comments = config.comments + self.created = config.created + self.helpers = config.helpers.dict + self.id = config.id + self.initrd = config.initrd + self.kernel = dict(LinodeKernelModel(config.kernel)) + self.label = config.label + self.linode_id = config.linode_id + self.memory_limit = config.memory_limit + self.parent_id_name = config.parent_id_name + self.root_device = config.root_device + self.run_level = config.run_level + self.updated = datetime.datetime.fromisoformat(config.updated) + self.virt_mode = config.virt_mode + + +class LinodeKernelModel(Mapping): + def __init__(self, kernel: Kernel): + super().__init__() + self.architecture = kernel.architecture + self.created = kernel.created + self.deprecated = kernel.deprecated + self.description = kernel.description + self.id = kernel.id + self.kvm = kernel.kvm + self.label = kernel.label + self.version = kernel.version + self.xen = kernel.xen + + +class LinodeBackupModel(Mapping): + def __init__(self, backup: Backup): + super().__init__() + self.created = backup.created + self.disks = { + disk.label: { + 'label': disk.label, + 'size': disk.size, + 'filesystem': disk.filesystem, + } + for disk in backup.disks + } + + self.duration = backup.duration + self.finished = backup.finished + self.id = backup.id + self.label = backup.label + self.linode_id = backup.linode_id + self.message = backup.message + self.parent_id_name = backup.parent_id_name + self.country = backup.region.country + self.status = backup.status + self.type = backup.type + self.updated = backup.updated + + +class LinodeDiskModel(Mapping): + def __init__(self, disk: Disk): + super().__init__() + self.created = disk.created + self.filesystem = disk.filesystem + self.id = disk.id + self.label = disk.label + self.linode_id = disk.linode_id + self.parent_id_name = disk.parent_id_name + self.size = disk.size + self.status = disk.status + self.updated = disk.updated + + +class LinodeImageModel(Mapping): + def __init__(self, image: Image): + super().__init__() + self.created = image.created + self.created_by = image.created_by + self.deprecated = image.deprecated + self.description = image.description + self.is_public = image.is_public + self.label = image.label + self.size = image.size + self.status = image.status + self.type = image.type + self.vendor = image.vendor + + +class LinodeTypeModel(Mapping): + # noinspection PyShadowingBuiltins + def __init__(self, type: Type): + super().__init__() + self.disk = type.disk + self.id = type.id + self.label = type.label + self.memory = type.memory + self.network_out = type.network_out + self.price = type.price.dict + self.transfer = type.transfer + self.type_class = type.type_class + self.vcpus = type.vcpus + + +class LinodeInstanceModel(Mapping): + def __init__(self, node: Instance): + super().__init__() + self.label = node.label + self.status = node.status + self.alerts = node.alerts.dict + self.available_backups = [ + dict(LinodeBackupModel(backup)) + for backup in node.available_backups.automatic + ], + + self.backups = { + 'enabled': node.backups.enabled, + 'schedule': node.backups.schedule.dict, + 'last_successful': datetime.datetime.fromisoformat(node.backups.last_successful), + } + + self.configs = {config.label: dict(LinodeConfigModel(config)) for config in node.configs} + self.disks = {disk.label: dict(LinodeDiskModel(disk)) for disk in node.disks} + self.group = node.group + self.hypervisor = node.hypervisor + self.id = node.id + self.image = LinodeImageModel(node.image) + self.country = node.region.country + self.specs = node.specs.dict + self.tags = node.tags + self.transfer = node.transfer.dict + self.type = dict(LinodeTypeModel(node.type)) + self.updated = node.updated + + +class LinodeInstanceResponse(LinodeResponse): + def __init__(self, instance: Instance, *args, **kwargs): + super().__init__(*args, output={ + 'instance': dict(LinodeInstanceModel(instance)) + }, **kwargs) + + +class LinodeInstancesResponse(LinodeResponse): + def __init__(self, + instances: List[Instance], + *args, **kwargs): + super().__init__(*args, output={ + 'instances': {instance.label: dict(LinodeInstanceModel(instance)) for instance in instances}, + }, **kwargs) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/linode.py b/platypush/plugins/linode.py new file mode 100644 index 00000000..16c42e0f --- /dev/null +++ b/platypush/plugins/linode.py @@ -0,0 +1,99 @@ +from typing import Optional, Union + +from linode_api4 import LinodeClient, Instance +from platypush.plugins.sensor import SensorPlugin + +from platypush.message.response.linode import LinodeInstancesResponse, LinodeInstanceResponse +from platypush.plugins import action + + +class LinodePlugin(SensorPlugin): + """ + This plugin can interact with a Linode account and manage node and volumes. + + To get your token: + + - Login to . + - Go to My Profile -> API Tokens -> Add a Personal Access Token. + - Select the scopes that you want to provide to your new token. + + Requires: + + * **linode_api4** (``pip install linode_api4``) + + """ + + def __init__(self, token: str, **kwargs): + """ + :param token: Your Linode token. + """ + + super().__init__(**kwargs) + self._token = token + + def _get_client(self, token: Optional[str] = None) -> LinodeClient: + return LinodeClient(token or self._token) + + def _get_instance(self, label: str, token: Optional[str] = None) -> Instance: + client = self._get_client(token) + instances = client.linode.instances(Instance.label == label) + assert instances, 'No such Linode instance: ' + label + return instances[0] + + @action + def status(self, token: Optional[str] = None, instance: Optional[str] = None) \ + -> Union[LinodeInstanceResponse, LinodeInstancesResponse]: + """ + Get the full status and info of the instances associated to a selected account. + + :param token: Override the default access token if you want to query another account. + :param instance: Select only one node by label. + :return: :class:`platypush.message.response.linode.LinodeInstanceResponse` if ``label`` is specified, + :class:`platypush.message.response.linode.LinodeInstancesResponse` otherwise. + """ + if instance: + instance = self._get_instance(label=instance) + return LinodeInstanceResponse(instance=instance) + + client = self._get_client(token) + return LinodeInstancesResponse(instances=client.linode.instances()) + + @action + def reboot(self, instance: str, token: Optional[str] = None) -> None: + """ + Reboot an instance. + + :param instance: Label of the instance to be rebooted. + :param token: Default access token override. + """ + instance = self._get_instance(label=instance, token=token) + assert instance.reboot(), 'Reboot failed' + + @action + def boot(self, instance: str, token: Optional[str] = None) -> None: + """ + Boot an instance. + + :param instance: Label of the instance to be booted. + :param token: Default access token override. + """ + instance = self._get_instance(label=instance, token=token) + assert instance.boot(), 'Boot failed' + + @action + def shutdown(self, instance: str, token: Optional[str] = None) -> None: + """ + Shutdown an instance. + + :param instance: Label of the instance to be shut down. + :param token: Default access token override. + """ + instance = self._get_instance(label=instance, token=token) + assert instance.shutdown(), 'Shutdown failed' + + @action + def get_measurement(self, *args, **kwargs): + return self.status(*args, **kwargs) + + +# vim:sw=4:ts=4:et: diff --git a/requirements.txt b/requirements.txt index 232721ae..aad0efb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -243,3 +243,6 @@ croniter # Support for OTP (One-Time Password) generation # pyotp + +# Support for Linode integration +# linode_api4 diff --git a/setup.py b/setup.py index a462fd12..2a9cc710 100755 --- a/setup.py +++ b/setup.py @@ -291,5 +291,7 @@ setup( 'picovoice-speech': ['pvcheetah @ git+https://github.com/BlackLight/cheetah'], # Support for OTP (One-Time Password) generation 'otp': ['pyotp'], + # Support for Linode integration + 'linode': ['linode_api4'], }, )