├── MANIFEST.in ├── requirements.txt ├── setup.cfg ├── .gitignore ├── .gitmodules ├── README.rst ├── gateway_addon ├── utils.py ├── errors.py ├── __init__.py ├── constants.py ├── event.py ├── outlet.py ├── action.py ├── api_handler_utils.py ├── api_handler.py ├── database.py ├── ipc.py ├── notifier.py ├── property.py ├── adapter.py ├── device.py └── addon_manager_proxy.py ├── .github └── workflows │ ├── projects.yml │ └── pythonpackage.yml ├── CODE_OF_CONDUCT.md ├── setup.py └── LICENSE.txt /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jsonschema==3.2.0 2 | singleton-decorator==1.0.0 3 | websocket-client==0.57.0 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This wheel supports both Python 2.x and Python 3.x. 3 | universal=1 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info/ 3 | *.py[cod] 4 | *.swp 5 | *~ 6 | __pycache__/ 7 | build/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "schema"] 2 | path = gateway_addon/schema 3 | url = https://github.com/WebThingsIO/gateway-addon-ipc-schema 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | gateway_addon 2 | ============= 3 | 4 | Python bindings for developing Python add-ons for WebThings Gateway. 5 | 6 | For a tutorial on building an add-on, see `this page `_. 7 | -------------------------------------------------------------------------------- /gateway_addon/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | 3 | import datetime 4 | 5 | 6 | def timestamp(): 7 | """ 8 | Get the current time. 9 | 10 | Returns the current time in the form YYYY-mm-ddTHH:MM:SS+00:00 11 | """ 12 | return datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S+00:00') 13 | -------------------------------------------------------------------------------- /.github/workflows/projects.yml: -------------------------------------------------------------------------------- 1 | name: Add new issues to the specified project column 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | add-new-issues-to-project-column: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: add-new-issues-to-organization-based-project-column 12 | uses: docker://takanabe/github-actions-automate-projects:v0.0.1 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.CI_TOKEN }} 15 | GITHUB_PROJECT_URL: https://github.com/orgs/WebThingsIO/projects/3 16 | GITHUB_PROJECT_COLUMN_NAME: Triage 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /gateway_addon/errors.py: -------------------------------------------------------------------------------- 1 | """Exception types.""" 2 | 3 | 4 | class ActionError(Exception): 5 | """Exception to indicate an issue with an action.""" 6 | 7 | pass 8 | 9 | 10 | class APIHandlerError(Exception): 11 | """Exception to indicate an issue with an API request handler.""" 12 | 13 | pass 14 | 15 | 16 | class PropertyError(Exception): 17 | """Exception to indicate an issue with a property.""" 18 | 19 | pass 20 | 21 | 22 | class SetPinError(Exception): 23 | """Exception to indicate an issue with setting a PIN.""" 24 | 25 | pass 26 | 27 | 28 | class SetCredentialsError(Exception): 29 | """Exception to indicate an issue with setting the credentials.""" 30 | 31 | pass 32 | 33 | 34 | class NotifyError(Exception): 35 | """Exception to indicate an issue with notifying the user.""" 36 | 37 | pass 38 | -------------------------------------------------------------------------------- /gateway_addon/__init__.py: -------------------------------------------------------------------------------- 1 | """This module provides a high-level interface for creating Gateway add-ons.""" 2 | 3 | # flake8: noqa 4 | from .action import Action 5 | from .adapter import Adapter 6 | from .addon_manager_proxy import AddonManagerProxy 7 | from .api_handler import APIHandler 8 | from .api_handler_utils import APIRequest, APIResponse 9 | from .database import Database 10 | from .device import Device 11 | from .errors import (ActionError, APIHandlerError, NotifyError, PropertyError, 12 | SetPinError, SetCredentialsError) 13 | from .event import Event 14 | from .ipc import IpcClient 15 | from .notifier import Notifier 16 | from .outlet import Outlet 17 | from .property import Property 18 | 19 | __version__ = '1.1.1' 20 | API_VERSION = 2 21 | 22 | 23 | def get_version(): 24 | """Get the version of this package.""" 25 | return __version__ 26 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: [3.7] 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | sudo apt install -y libnanomsg-dev 26 | python -m pip install --upgrade pip 27 | pip install -r requirements.txt 28 | - name: Lint with flake8 and pydocstyle 29 | run: | 30 | pip install flake8 pydocstyle 31 | flake8 gateway_addon --exclude schema --count --max-line-length=79 --statistics 32 | pydocstyle --match-dir '^($!schama).*' gateway_addon 33 | -------------------------------------------------------------------------------- /gateway_addon/constants.py: -------------------------------------------------------------------------------- 1 | """WebThings Gateway Constants.""" 2 | 3 | import glob 4 | import json 5 | import os 6 | 7 | 8 | class MessageType: 9 | """Enumeration of IPC message types.""" 10 | 11 | pass 12 | 13 | 14 | # Build up message types dynamically from schemas 15 | for fname in glob.glob(os.path.realpath( 16 | os.path.join(os.path.dirname(__file__), 'schema', 'messages', '*.json') 17 | )): 18 | with open(fname, 'rt') as f: 19 | schema = json.load(f) 20 | 21 | if 'properties' not in schema or 'messageType' not in schema['properties']: 22 | continue 23 | 24 | name = fname.split('/')[-1].split('.')[0].upper().replace('-', '_') 25 | value = schema['properties']['messageType']['const'] 26 | 27 | setattr(MessageType, name, value) 28 | 29 | 30 | class NotificationLevel: 31 | """Enumeration of notification levels.""" 32 | 33 | LOW = 0 34 | NORMAL = 1 35 | HIGH = 2 36 | 37 | 38 | DONT_RESTART_EXIT_CODE = 100 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module.""" 2 | 3 | from setuptools import setup, find_packages 4 | from codecs import open 5 | from os import path 6 | import subprocess 7 | import sys 8 | 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | 12 | # Get the long description from the README file. 13 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 14 | long_description = f.read() 15 | 16 | # Pull in the schemas 17 | subprocess.run( 18 | 'git submodule init && git submodule update', 19 | shell=True, 20 | cwd=here, 21 | check=True, 22 | ) 23 | 24 | requirements = [ 25 | 'jsonschema==3.2.0', 26 | 'singleton-decorator==1.0.0', 27 | 'websocket-client==0.57.0', 28 | ] 29 | 30 | setup( 31 | name='gateway_addon', 32 | version='1.1.1', 33 | description='Bindings for WebThings Gateway add-ons', 34 | long_description=long_description, 35 | url='https://github.com/WebThingsIO/gateway-addon-python', 36 | author='WebThingsIO', 37 | keywords='webthings gateway addon add-on', 38 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 39 | package_data={'': ['schema/schema.json', 'schema/**/*.json']}, 40 | install_requires=requirements, 41 | ) 42 | -------------------------------------------------------------------------------- /gateway_addon/event.py: -------------------------------------------------------------------------------- 1 | """High-level Event base class implementation.""" 2 | 3 | from .utils import timestamp 4 | 5 | 6 | class Event: 7 | """An Event represents an individual event from a device.""" 8 | 9 | def __init__(self, device, name, data=None): 10 | """ 11 | Initialize the object. 12 | 13 | device -- device this event belongs to 14 | name -- name of the event 15 | data -- data associated with the event 16 | """ 17 | self.device = device 18 | self.name = name 19 | self.data = data 20 | self.timestamp = timestamp() 21 | 22 | def as_event_description(self): 23 | """ 24 | Get the event description. 25 | 26 | Returns a dictionary describing the event. 27 | """ 28 | description = { 29 | 'name': self.name, 30 | 'timestamp': self.timestamp, 31 | } 32 | 33 | if self.data is not None: 34 | description['data'] = self.data 35 | 36 | return description 37 | 38 | def as_dict(self): 39 | """ 40 | Get the event description. 41 | 42 | Returns a dictionary describing the event. 43 | """ 44 | return self.as_event_description() 45 | -------------------------------------------------------------------------------- /gateway_addon/outlet.py: -------------------------------------------------------------------------------- 1 | """High-level Outlet base class implementation.""" 2 | 3 | from __future__ import print_function 4 | import functools 5 | 6 | 7 | print = functools.partial(print, flush=True) 8 | 9 | 10 | class Outlet: 11 | """An Outlet represents a notification channel for a Notifier.""" 12 | 13 | def __init__(self, notifier, _id): 14 | """ 15 | Initialize the object. 16 | 17 | notifier -- the Notifier managing this outlet 18 | _id -- the outlet's individual ID 19 | """ 20 | self.notifier = notifier 21 | self.id = str(_id) 22 | self.name = '' 23 | 24 | def as_dict(self): 25 | """ 26 | Get the outlet state as a dictionary. 27 | 28 | Returns the state as a dictionary. 29 | """ 30 | return { 31 | 'id': self.id, 32 | 'name': self.name, 33 | } 34 | 35 | def get_id(self): 36 | """ 37 | Get the ID of the outlet. 38 | 39 | Returns the ID as a string. 40 | """ 41 | return self.id 42 | 43 | def get_name(self): 44 | """ 45 | Get the name of the outlet. 46 | 47 | Returns the name as a string. 48 | """ 49 | return self.name 50 | 51 | def notify(self, title, message, level): 52 | """ 53 | Notify the user. 54 | 55 | title -- title of notification 56 | message -- message of notification 57 | level -- alert level 58 | """ 59 | if self.notifier.verbose: 60 | print('Outlet: {} notify("{}", "{}", {})' 61 | .format(self.name, title, message, level)) 62 | -------------------------------------------------------------------------------- /gateway_addon/action.py: -------------------------------------------------------------------------------- 1 | """High-level Action base class implementation.""" 2 | 3 | from .utils import timestamp 4 | 5 | 6 | class Action: 7 | """An Action represents an individual action on a device.""" 8 | 9 | def __init__(self, id_, device, name, input_): 10 | """ 11 | Initialize the object. 12 | 13 | id_ ID of this action 14 | device -- the device this action belongs to 15 | name -- name of the action 16 | input_ -- any action inputs 17 | """ 18 | self.id = id_ 19 | self.device = device 20 | self.name = name 21 | self.input = input_ 22 | self.status = 'created' 23 | self.time_requested = timestamp() 24 | self.time_completed = None 25 | 26 | def as_action_description(self): 27 | """ 28 | Get the action description. 29 | 30 | Returns a dictionary describing the action. 31 | """ 32 | description = { 33 | 'name': self.name, 34 | 'timeRequested': self.time_requested, 35 | 'status': self.status, 36 | } 37 | 38 | if self.input is not None: 39 | description['input'] = self.input 40 | 41 | if self.time_completed is not None: 42 | description['timeCompleted'] = self.time_completed 43 | 44 | return description 45 | 46 | def as_dict(self): 47 | """ 48 | Get the action description. 49 | 50 | Returns a dictionary describing the action. 51 | """ 52 | d = self.as_action_description() 53 | d['id'] = self.id 54 | return d 55 | 56 | def start(self): 57 | """Start performing the action.""" 58 | self.status = 'pending' 59 | self.device.action_notify(self) 60 | 61 | def finish(self): 62 | """Finish performing the action.""" 63 | self.status = 'completed' 64 | self.time_completed = timestamp() 65 | self.device.action_notify(self) 66 | -------------------------------------------------------------------------------- /gateway_addon/api_handler_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions and classes for API handlers.""" 2 | 3 | import pprint 4 | 5 | 6 | class APIRequest: 7 | """Class which holds an API request.""" 8 | 9 | def __init__(self, **kwargs): 10 | """Initialize the object.""" 11 | self.method = kwargs.get('method', '') 12 | self.path = kwargs.get('path', '') 13 | self.query = kwargs.get('query', {}) 14 | self.body = kwargs.get('body', {}) 15 | 16 | def __str__(self): 17 | """Format this object as a string.""" 18 | return pprint.pformat({ 19 | 'method': self.method, 20 | 'path': self.path, 21 | 'query': self.query, 22 | 'body': self.body, 23 | }) 24 | 25 | 26 | class APIResponse: 27 | """Class which holds an API response.""" 28 | 29 | def __init__(self, **kwargs): 30 | """Initialize the object.""" 31 | self.status = kwargs.get('status', None) 32 | if self.status is None: 33 | self.status = 500 34 | self.content_type = None 35 | self.content = None 36 | return 37 | 38 | self.content_type = kwargs.get('content_type', None) 39 | if self.content_type is not None and \ 40 | type(self.content_type) is not str: 41 | self.content_type = str(self.content_type) 42 | 43 | self.content = kwargs.get('content', None) 44 | if self.content is not None and type(self.content) is not str: 45 | self.content = str(self.content) 46 | 47 | def __str__(self): 48 | """Format this object as a string.""" 49 | return pprint.pformat({ 50 | 'status': self.status, 51 | 'content_type': self.content_type, 52 | 'content': self.content, 53 | }) 54 | 55 | def to_json(self): 56 | """Return JSON representation of this object for IPC.""" 57 | return { 58 | 'status': self.status, 59 | 'contentType': self.content_type, 60 | 'content': self.content, 61 | } 62 | -------------------------------------------------------------------------------- /gateway_addon/api_handler.py: -------------------------------------------------------------------------------- 1 | """High-level API Handler base class implementation.""" 2 | 3 | from __future__ import print_function 4 | import functools 5 | 6 | from .addon_manager_proxy import AddonManagerProxy 7 | from .api_handler_utils import APIResponse 8 | 9 | 10 | print = functools.partial(print, flush=True) 11 | 12 | 13 | class APIHandler: 14 | """An API handler represents a way of extending the gateway's REST API.""" 15 | 16 | def __init__(self, package_name, verbose=False): 17 | """ 18 | Initialize the object. 19 | 20 | As part of initialization, a connection is established between the 21 | handler and the Gateway via nanomsg IPC. 22 | 23 | package_name -- the handler's package name 24 | verbose -- whether or not to enable verbose logging 25 | """ 26 | self.package_name = package_name 27 | 28 | self.verbose = verbose 29 | self.manager_proxy = \ 30 | AddonManagerProxy(self.package_name, verbose=verbose) 31 | self.manager_proxy.add_api_handler(self) 32 | 33 | self.gateway_version = self.manager_proxy.gateway_version 34 | self.user_profile = self.manager_proxy.user_profile 35 | self.preferences = self.manager_proxy.preferences 36 | 37 | def proxy_running(self): 38 | """Return boolean indicating whether or not the proxy is running.""" 39 | return self.manager_proxy.running 40 | 41 | def close_proxy(self): 42 | """Close the manager proxy.""" 43 | self.manager_proxy.close() 44 | 45 | def send_error(self, message): 46 | """ 47 | Send an error notification. 48 | 49 | message -- error message 50 | """ 51 | self.manager_proxy.send_error(message) 52 | 53 | def get_package_name(self): 54 | """ 55 | Get the package name of the handler. 56 | 57 | Returns the package name as a string. 58 | """ 59 | return self.package_name 60 | 61 | def handle_request(self, request): 62 | """ 63 | Handle a new API request for this handler. 64 | 65 | request -- APIRequest object 66 | """ 67 | if self.verbose: 68 | print('New API request for {}: {}' 69 | .format(self.package_name, request)) 70 | 71 | return APIResponse(status=404) 72 | 73 | def unload(self): 74 | """Perform any necessary cleanup before handler is shut down.""" 75 | if self.verbose: 76 | print('API Handler:', self.package_name, 'unloaded') 77 | -------------------------------------------------------------------------------- /gateway_addon/database.py: -------------------------------------------------------------------------------- 1 | """Wrapper around the gateway's database.""" 2 | 3 | import json 4 | import os 5 | import sqlite3 6 | 7 | 8 | _DB_PATHS = [ 9 | os.path.join(os.path.expanduser('~'), 10 | '.webthings', 11 | 'config', 12 | 'db.sqlite3'), 13 | ] 14 | 15 | if 'WEBTHINGS_HOME' in os.environ: 16 | _DB_PATHS.insert( 17 | 0, 18 | os.path.join(os.environ['WEBTHINGS_HOME'], 'config', 'db.sqlite3') 19 | ) 20 | 21 | if 'WEBTHINGS_DATABASE' in os.environ: 22 | _DB_PATHS.insert(0, os.environ['WEBTHINGS_DATABASE']) 23 | 24 | 25 | class Database: 26 | """Wrapper around gateway's settings database.""" 27 | 28 | def __init__(self, package_name, path=None): 29 | """ 30 | Initialize the object. 31 | 32 | package_name -- the adapter's package name 33 | path -- optional path to the database 34 | """ 35 | self.package_name = package_name 36 | self.path = path 37 | self.conn = None 38 | 39 | if self.path is None: 40 | for p in _DB_PATHS: 41 | if os.path.isfile(p): 42 | self.path = p 43 | break 44 | 45 | def open(self): 46 | """Open the database.""" 47 | if self.path is None: 48 | return False 49 | 50 | self.conn = sqlite3.connect(self.path) 51 | return True 52 | 53 | def close(self): 54 | """Close the database.""" 55 | if self.conn: 56 | self.conn.close() 57 | self.conn = None 58 | 59 | def load_config(self): 60 | """Load the package's config from the database.""" 61 | if not self.conn: 62 | return None 63 | 64 | key = 'addons.config.{}'.format(self.package_name) 65 | c = self.conn.cursor() 66 | c.execute('SELECT value FROM settings WHERE key = ?', (key,)) 67 | data = c.fetchone() 68 | c.close() 69 | 70 | if not data: 71 | return {} 72 | 73 | return json.loads(data[0]) 74 | 75 | def save_config(self, config): 76 | """Save the package's config in the database.""" 77 | if not self.conn: 78 | return False 79 | 80 | key = 'addons.config.{}'.format(self.package_name) 81 | c = self.conn.cursor() 82 | c.execute('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', 83 | (key, json.dumps(config))) 84 | self.conn.commit() 85 | c.close() 86 | 87 | return True 88 | -------------------------------------------------------------------------------- /gateway_addon/ipc.py: -------------------------------------------------------------------------------- 1 | """IPC client to communicate with the Gateway.""" 2 | 3 | from __future__ import print_function 4 | import functools 5 | import json 6 | import jsonschema 7 | import os 8 | import threading 9 | import time 10 | import websocket 11 | 12 | from .constants import MessageType 13 | 14 | 15 | _IPC_PORT = 9500 16 | _SCHEMA_DIR = os.path.realpath( 17 | os.path.join(os.path.dirname(__file__), 'schema') 18 | ) 19 | 20 | print = functools.partial(print, flush=True) 21 | 22 | 23 | class Resolver(jsonschema.RefResolver): 24 | """Resolver for $ref members in schemas.""" 25 | 26 | def __init__(self): 27 | """Initialize the resolver.""" 28 | jsonschema.RefResolver.__init__( 29 | self, 30 | base_uri='', 31 | referrer=None, 32 | cache_remote=True, 33 | ) 34 | 35 | def resolve_remote(self, uri): 36 | """ 37 | Resolve a remote URI. We only look locally. 38 | 39 | uri -- the URI to resolve 40 | """ 41 | name = uri.split('/')[-1] 42 | local = os.path.join(_SCHEMA_DIR, 'messages', name) 43 | 44 | if os.path.exists(local): 45 | with open(local, 'rt') as f: 46 | return json.load(f) 47 | else: 48 | print('Unable to find referenced schema:', name) 49 | 50 | 51 | class IpcClient: 52 | """IPC client which can communicate between the Gateway and an add-on.""" 53 | 54 | def __init__(self, plugin_id, on_message, verbose=False): 55 | """ 56 | Initialize the object. 57 | 58 | plugin_id -- ID of this plugin 59 | on_message -- message handler 60 | verbose -- whether or not to enable verbose logging 61 | """ 62 | with open(os.path.join(_SCHEMA_DIR, 'schema.json'), 'rt') as f: 63 | schema = json.load(f) 64 | 65 | self.plugin_id = plugin_id 66 | self.verbose = verbose 67 | self.owner_message_handler = on_message 68 | 69 | self.validator = jsonschema.Draft7Validator( 70 | schema=schema, 71 | resolver=Resolver() 72 | ) 73 | 74 | self.registered = False 75 | 76 | self.ws = websocket.WebSocketApp( 77 | 'ws://127.0.0.1:{}/'.format(_IPC_PORT), 78 | on_open=self.on_open, 79 | on_message=self.on_message, 80 | ) 81 | 82 | self.thread = threading.Thread(target=self.ws.run_forever) 83 | self.thread.daemon = True 84 | self.thread.start() 85 | 86 | while not self.registered: 87 | time.sleep(0.01) 88 | 89 | def on_open(self): 90 | """Event handler for WebSocket opening.""" 91 | if self.verbose: 92 | print('IpcClient: Connected to server, registering...') 93 | 94 | try: 95 | self.ws.send(json.dumps({ 96 | 'messageType': MessageType.PLUGIN_REGISTER_REQUEST, 97 | 'data': { 98 | 'pluginId': self.plugin_id, 99 | } 100 | })) 101 | except websocket.WebSocketException as e: 102 | print('IpcClient: Failed to send message: {}'.format(e)) 103 | return 104 | 105 | def on_message(self, message): 106 | """ 107 | Event handler for WebSocket messages. 108 | 109 | message -- the received message 110 | """ 111 | try: 112 | resp = json.loads(message) 113 | 114 | self.validator.validate({'message': resp}) 115 | 116 | if resp['messageType'] == MessageType.PLUGIN_REGISTER_RESPONSE: 117 | if self.verbose: 118 | print('IpcClient: Registered with PluginServer') 119 | 120 | self.gateway_version = resp['data']['gatewayVersion'] 121 | self.user_profile = resp['data']['userProfile'] 122 | self.preferences = resp['data']['preferences'] 123 | self.registered = True 124 | else: 125 | self.owner_message_handler(resp) 126 | except ValueError: 127 | print('IpcClient: Unexpected registration reply from gateway: {}' 128 | .format(resp)) 129 | except jsonschema.exceptions.ValidationError: 130 | print('Invalid message received:', resp) 131 | 132 | def close(self): 133 | """Close the WebSocket.""" 134 | self.ws.close() 135 | -------------------------------------------------------------------------------- /gateway_addon/notifier.py: -------------------------------------------------------------------------------- 1 | """High-level Notifier base class implementation.""" 2 | 3 | from __future__ import print_function 4 | import functools 5 | 6 | from .addon_manager_proxy import AddonManagerProxy 7 | 8 | 9 | print = functools.partial(print, flush=True) 10 | 11 | 12 | class Notifier: 13 | """A Notifier represents a way of sending alerts to a user.""" 14 | 15 | def __init__(self, _id, package_name, verbose=False): 16 | """ 17 | Initialize the object. 18 | 19 | As part of initialization, a connection is established between the 20 | notifier and the Gateway via nanomsg IPC. 21 | 22 | _id -- the notifier's individual ID 23 | package_name -- the notifier's package name 24 | verbose -- whether or not to enable verbose logging 25 | """ 26 | self.id = _id 27 | self.package_name = package_name 28 | self.outlets = {} 29 | 30 | # We assume that the notifier is ready right away. If, for some reason, 31 | # a particular notifier needs some time, then it should set ready to 32 | # False in its constructor. 33 | self.ready = True 34 | 35 | self.verbose = verbose 36 | self.manager_proxy = \ 37 | AddonManagerProxy(self.package_name, verbose=verbose) 38 | self.manager_proxy.add_notifier(self) 39 | 40 | self.gateway_version = self.manager_proxy.gateway_version 41 | self.user_profile = self.manager_proxy.user_profile 42 | self.preferences = self.manager_proxy.preferences 43 | 44 | def proxy_running(self): 45 | """Return boolean indicating whether or not the proxy is running.""" 46 | return self.manager_proxy.running 47 | 48 | def close_proxy(self): 49 | """Close the manager proxy.""" 50 | self.manager_proxy.close() 51 | 52 | def send_error(self, message): 53 | """ 54 | Send an error notification. 55 | 56 | message -- error message 57 | """ 58 | self.manager_proxy.send_error(message) 59 | 60 | def dump(self): 61 | """Dump the state of the notifier to the log.""" 62 | if self.verbose: 63 | print('Notifier:', self.name, '- dump() not implemented') 64 | 65 | def get_id(self): 66 | """ 67 | Get the ID of the notifier. 68 | 69 | Returns the ID as a string. 70 | """ 71 | return self.id 72 | 73 | def get_package_name(self): 74 | """ 75 | Get the package name of the notifier. 76 | 77 | Returns the package name as a string. 78 | """ 79 | return self.package_name 80 | 81 | def get_outlet(self, outlet_id): 82 | """ 83 | Get the outlet with the given ID. 84 | 85 | outlet_id -- ID of outlet to retrieve 86 | 87 | Returns an Outlet object, if found, else None. 88 | """ 89 | return self.outlets.get(outlet_id, None) 90 | 91 | def get_outlets(self): 92 | """ 93 | Get all the outlets managed by this notifier. 94 | 95 | Returns a dictionary of outlet_id -> Outlet. 96 | """ 97 | return self.outlets 98 | 99 | def get_name(self): 100 | """ 101 | Get the name of this notifier. 102 | 103 | Returns the name as a string. 104 | """ 105 | return self.name 106 | 107 | def is_ready(self): 108 | """ 109 | Get the ready state of this notifier. 110 | 111 | Returns the ready state as a boolean. 112 | """ 113 | return self.ready 114 | 115 | def as_dict(self): 116 | """ 117 | Get the notifier state as a dictionary. 118 | 119 | Returns the state as a dictionary. 120 | """ 121 | return { 122 | 'id': self.id, 123 | 'name': self.name, 124 | 'ready': self.ready, 125 | } 126 | 127 | def handle_outlet_added(self, outlet): 128 | """ 129 | Notify the Gateway that a new outlet is being managed by this notifier. 130 | 131 | outlet -- Outlet object 132 | """ 133 | self.outlets[outlet.id] = outlet 134 | self.manager_proxy.handle_outlet_added(outlet) 135 | 136 | def handle_outlet_removed(self, outlet): 137 | """ 138 | Notify the Gateway that an outlet has been removed. 139 | 140 | outlet -- Outlet object 141 | """ 142 | if outlet.id in self.outlets: 143 | del self.outlets[outlet.id] 144 | 145 | self.manager_proxy.handle_outlet_removed(outlet) 146 | 147 | def unload(self): 148 | """Perform any necessary cleanup before notifier is shut down.""" 149 | if self.verbose: 150 | print('Notifier:', self.name, 'unloaded') 151 | -------------------------------------------------------------------------------- /gateway_addon/property.py: -------------------------------------------------------------------------------- 1 | """High-level Property base class implementation.""" 2 | 3 | from .errors import PropertyError 4 | import warnings 5 | 6 | 7 | class Property: 8 | """A Property represents an individual state value of a device.""" 9 | 10 | def __init__(self, device, name, description): 11 | """ 12 | Initialize the object. 13 | 14 | device -- the Device this property belongs to 15 | name -- name of the property 16 | description -- description of the property, as a dictionary 17 | """ 18 | self.device = device 19 | self.name = name 20 | self.value = None 21 | self.description = {} 22 | self.visible = True 23 | self.fire_and_forget = False 24 | 25 | # Check 'visible' for backwards compatibility 26 | if 'visible' in description: 27 | warnings.warn('''The visible member of property descriptions is 28 | deprecated.''', DeprecationWarning) 29 | self.visible = description['visible'] 30 | 31 | # Check 'min' and 'max' for backwards compatibility 32 | if 'min' in description: 33 | self.description['minimum'] = description['min'] 34 | 35 | if 'max' in description: 36 | self.description['maximum'] = description['max'] 37 | 38 | # Check 'label' for backwards compatibility 39 | if 'label' in description: 40 | self.description['title'] = description['label'] 41 | 42 | fields = [ 43 | 'title', 44 | 'type', 45 | '@type', 46 | 'unit', 47 | 'description', 48 | 'minimum', 49 | 'maximum', 50 | 'enum', 51 | 'readOnly', 52 | 'multipleOf', 53 | 'links', 54 | ] 55 | for field in fields: 56 | if field in description: 57 | self.description[field] = description[field] 58 | 59 | def as_dict(self): 60 | """ 61 | Get the property state as a dictionary. 62 | 63 | Returns the state as a dictionary. 64 | """ 65 | prop = { 66 | 'name': self.name, 67 | 'value': self.value, 68 | 'visible': self.visible, 69 | } 70 | prop.update(self.description) 71 | return prop 72 | 73 | def as_property_description(self): 74 | """ 75 | Get the property description. 76 | 77 | Returns a dictionary describing the property. 78 | """ 79 | return self.description 80 | 81 | def set_cached_value_and_notify(self, value): 82 | """ 83 | Set the cached value of the property and notify the device if changed. 84 | 85 | value -- the value to set 86 | 87 | Returns True if the value has changed, False otherwise. 88 | """ 89 | old_value = self.value 90 | self.set_cached_value(value) 91 | 92 | # set_cached_value may change the value, therefore we have to check 93 | # self.value after the call to set_cached_value 94 | has_changed = old_value != self.value 95 | 96 | if has_changed: 97 | self.device.notify_property_changed(self) 98 | 99 | return has_changed 100 | 101 | def set_cached_value(self, value): 102 | """ 103 | Set the cached value of the property, making adjustments as necessary. 104 | 105 | value -- the value to set 106 | 107 | Returns the value that was set. 108 | """ 109 | if 'type' in self.description and \ 110 | self.description['type'] == 'boolean': 111 | self.value = bool(value) 112 | else: 113 | self.value = value 114 | 115 | return self.value 116 | 117 | def get_value(self): 118 | """ 119 | Get the current property value. 120 | 121 | Returns the value. 122 | """ 123 | return self.value 124 | 125 | def set_value(self, value): 126 | """ 127 | Set the current value of the property. 128 | 129 | value -- the value to set 130 | """ 131 | if 'readOnly' in self.description and self.description['readOnly']: 132 | raise PropertyError('Read-only property') 133 | 134 | if 'minimum' in self.description and \ 135 | value < self.description['minimum']: 136 | raise PropertyError('Value less than minimum: {}' 137 | .format(self.description['minimum'])) 138 | 139 | if 'maximum' in self.description and \ 140 | value > self.description['maximum']: 141 | raise PropertyError('Value greater than maximum: {}' 142 | .format(self.description['maximum'])) 143 | 144 | if 'multipleOf' in self.description: 145 | # note that we don't use the modulus operator here because it's 146 | # unreliable for floating point numbers 147 | multiple_of = self.description['multipleOf'] 148 | if value / multiple_of - round(value / multiple_of) != 0: 149 | raise PropertyError('Value is not a multiple of: {}' 150 | .format(multiple_of)) 151 | 152 | if 'enum' in self.description and \ 153 | len(self.description['enum']) > 0 and \ 154 | value not in self.description['enum']: 155 | raise PropertyError('Invalid enum value') 156 | 157 | self.set_cached_value_and_notify(value) 158 | -------------------------------------------------------------------------------- /gateway_addon/adapter.py: -------------------------------------------------------------------------------- 1 | """High-level Adapter base class implementation.""" 2 | 3 | from __future__ import print_function 4 | import functools 5 | 6 | from .addon_manager_proxy import AddonManagerProxy 7 | from .errors import SetCredentialsError, SetPinError 8 | 9 | 10 | print = functools.partial(print, flush=True) 11 | 12 | 13 | class Adapter: 14 | """An Adapter represents a way of communicating with a set of devices.""" 15 | 16 | def __init__(self, _id, package_name, verbose=False): 17 | """ 18 | Initialize the object. 19 | 20 | As part of initialization, a connection is established between the 21 | adapter and the Gateway via nanomsg IPC. 22 | 23 | _id -- the adapter's individual ID 24 | package_name -- the adapter's package name 25 | verbose -- whether or not to enable verbose logging 26 | """ 27 | self.id = _id 28 | self.package_name = package_name 29 | self.devices = {} 30 | self.actions = {} 31 | 32 | # We assume that the adapter is ready right away. If, for some reason, 33 | # a particular adapter needs some time, then it should set ready to 34 | # False in its constructor. 35 | self.ready = True 36 | 37 | self.verbose = verbose 38 | self.manager_proxy = \ 39 | AddonManagerProxy(self.package_name, verbose=verbose) 40 | self.manager_proxy.add_adapter(self) 41 | 42 | self.gateway_version = self.manager_proxy.gateway_version 43 | self.user_profile = self.manager_proxy.user_profile 44 | self.preferences = self.manager_proxy.preferences 45 | 46 | def proxy_running(self): 47 | """Return boolean indicating whether or not the proxy is running.""" 48 | return self.manager_proxy.running 49 | 50 | def close_proxy(self): 51 | """Close the manager proxy.""" 52 | self.manager_proxy.close() 53 | 54 | def send_error(self, message): 55 | """ 56 | Send an error notification. 57 | 58 | message -- error message 59 | """ 60 | self.manager_proxy.send_error(message) 61 | 62 | def dump(self): 63 | """Dump the state of the adapter to the log.""" 64 | if self.verbose: 65 | print('Adapter:', self.name, '- dump() not implemented') 66 | 67 | def get_id(self): 68 | """ 69 | Get the ID of the adapter. 70 | 71 | Returns the ID as a string. 72 | """ 73 | return self.id 74 | 75 | def get_package_name(self): 76 | """ 77 | Get the package name of the adapter. 78 | 79 | Returns the package name as a string. 80 | """ 81 | return self.package_name 82 | 83 | def get_device(self, device_id): 84 | """ 85 | Get the device with the given ID. 86 | 87 | device_id -- ID of device to retrieve 88 | 89 | Returns a Device object, if found, else None. 90 | """ 91 | return self.devices.get(device_id, None) 92 | 93 | def get_devices(self): 94 | """ 95 | Get all the devices managed by this adapter. 96 | 97 | Returns a dictionary of device_id -> Device. 98 | """ 99 | return self.devices 100 | 101 | def get_name(self): 102 | """ 103 | Get the name of this adapter. 104 | 105 | Returns the name as a string. 106 | """ 107 | return self.name 108 | 109 | def is_ready(self): 110 | """ 111 | Get the ready state of this adapter. 112 | 113 | Returns the ready state as a boolean. 114 | """ 115 | return self.ready 116 | 117 | def as_dict(self): 118 | """ 119 | Get the adapter state as a dictionary. 120 | 121 | Returns the state as a dictionary. 122 | """ 123 | return { 124 | 'id': self.id, 125 | 'name': self.name, 126 | 'ready': self.ready, 127 | } 128 | 129 | def handle_device_added(self, device): 130 | """ 131 | Notify the Gateway that a new device is being managed by this adapter. 132 | 133 | device -- Device object 134 | """ 135 | self.devices[device.id] = device 136 | self.manager_proxy.handle_device_added(device) 137 | 138 | def handle_device_removed(self, device): 139 | """ 140 | Notify the Gateway that a device has been removed. 141 | 142 | device -- Device object 143 | """ 144 | if device.id in self.devices: 145 | del self.devices[device.id] 146 | 147 | self.manager_proxy.handle_device_removed(device) 148 | 149 | def handle_device_saved(self, device_id, device): 150 | """ 151 | Handle an indication that the user has saved a device to their gateway. 152 | 153 | This is also called when the adapter starts up for every device which 154 | has already been saved. 155 | 156 | This can be used for keeping track of what devices have previously been 157 | discovered, such that the adapter can rebuild those, clean up old 158 | nodes, etc. 159 | 160 | device_id -- ID of the device 161 | device -- dict containing the saved device description 162 | """ 163 | pass 164 | 165 | def start_pairing(self, timeout): 166 | """ 167 | Start the pairing process. 168 | 169 | timeout -- Timeout in seconds at which to quit pairing 170 | """ 171 | if self.verbose: 172 | print('Adapter:', self.name, 'id', self.id, 'pairing started') 173 | 174 | def send_pairing_prompt(self, prompt, url=None, device=None): 175 | """ 176 | Send a prompt to the UI notifying the user to take some action. 177 | 178 | prompt -- The prompt to send 179 | url -- URL to site with further explanation or troubleshooting info 180 | device -- Device the prompt is associated with 181 | """ 182 | self.manager_proxy.send_pairing_prompt(self, prompt, url, device) 183 | 184 | def send_unpairing_prompt(self, prompt, url=None, device=None): 185 | """ 186 | Send a prompt to the UI notifying the user to take some action. 187 | 188 | prompt -- The prompt to send 189 | url -- URL to site with further explanation or troubleshooting info 190 | device -- Device the prompt is associated with 191 | """ 192 | self.manager_proxy.send_unpairing_prompt(self, prompt, url, device) 193 | 194 | def cancel_pairing(self): 195 | """Cancel the pairing process.""" 196 | if self.verbose: 197 | print('Adapter:', self.name, 'id', self.id, 'pairing cancelled') 198 | 199 | def remove_thing(self, device_id): 200 | """ 201 | Unpair a device with the adapter. 202 | 203 | device_id -- ID of device to unpair 204 | """ 205 | device = self.get_device(device_id) 206 | if device: 207 | if self.verbose: 208 | print('Adapter:', self.name, 'id', self.id, 209 | 'remove_thing(' + device.id + ')') 210 | 211 | self.handle_device_removed(device) 212 | 213 | def cancel_remove_thing(self, device_id): 214 | """ 215 | Cancel unpairing of a device. 216 | 217 | device_id -- ID of device to cancel unpairing with 218 | """ 219 | device = self.get_device(device_id) 220 | if device and self.verbose: 221 | print('Adapter:', self.name, 'id', self.id, 222 | 'cancel_remove_thing(' + device.id + ')') 223 | 224 | def unload(self): 225 | """Perform any necessary cleanup before adapter is shut down.""" 226 | if self.verbose: 227 | print('Adapter:', self.name, 'unloaded') 228 | 229 | def set_pin(self, device_id, pin): 230 | """ 231 | Set the PIN for the given device. 232 | 233 | device_id -- ID of device 234 | pin -- PIN to set 235 | """ 236 | device = self.get_device(device_id) 237 | if device: 238 | if self.verbose: 239 | print('Adapter:', self.name, 'id', self.id, 240 | 'set_pin(' + device.id + ', ' + pin + ')') 241 | else: 242 | raise SetPinError('Device not found') 243 | 244 | def set_credentials(self, device_id, username, password): 245 | """ 246 | Set the username and password for the given device. 247 | 248 | device_id -- ID of device 249 | username -- Username to set 250 | password -- Password to set 251 | """ 252 | device = self.get_device(device_id) 253 | if device: 254 | if self.verbose: 255 | print('Adapter:', self.name, 'id', self.id, 256 | 'set_credentials(' + device.id + ', ' + username + ', ' + 257 | password + ')') 258 | else: 259 | raise SetCredentialsError('Device not found') 260 | -------------------------------------------------------------------------------- /gateway_addon/device.py: -------------------------------------------------------------------------------- 1 | """High-level Device base class implementation.""" 2 | 3 | from jsonschema import validate 4 | from jsonschema.exceptions import ValidationError 5 | 6 | from .action import Action 7 | 8 | 9 | class Device: 10 | """A Device represents a physical object being managed by an Adapter.""" 11 | 12 | def __init__(self, adapter, _id): 13 | """ 14 | Initialize the object. 15 | 16 | adapter -- the Adapter managing this device 17 | _id -- the device's individual ID 18 | """ 19 | self.adapter = adapter 20 | self.id = str(_id) 21 | self._context = 'https://webthings.io/schemas' 22 | self._type = [] 23 | self.title = '' 24 | self.description = '' 25 | self.properties = {} 26 | self.actions = {} 27 | self.events = {} 28 | self.links = [] 29 | self.base_href = '' 30 | self.pin_required = False 31 | self.pin_pattern = '' 32 | self.credentials_required = False 33 | 34 | def as_dict(self): 35 | """ 36 | Get the device state as a dictionary. 37 | 38 | Returns the state as a dictionary. 39 | """ 40 | properties = {k: v.as_dict() for k, v in self.properties.items()} 41 | 42 | if hasattr(self, 'name') and not self.title: 43 | self.title = self.name 44 | 45 | return { 46 | 'id': self.id, 47 | 'title': self.title, 48 | '@context': self._context, 49 | '@type': self._type, 50 | 'description': self.description, 51 | 'properties': properties, 52 | 'actions': self.actions, 53 | 'events': self.events, 54 | 'links': self.links, 55 | 'baseHref': self.base_href, 56 | 'pin': { 57 | 'required': self.pin_required, 58 | 'pattern': self.pin_pattern, 59 | }, 60 | 'credentialsRequired': self.credentials_required, 61 | } 62 | 63 | def as_thing(self): 64 | """ 65 | Return the device state as a Thing Description. 66 | 67 | Returns the state as a dictionary. 68 | """ 69 | if hasattr(self, 'name') and not self.title: 70 | self.title = self.name 71 | 72 | thing = { 73 | 'id': self.id, 74 | 'title': self.title, 75 | '@context': self._context, 76 | '@type': self._type, 77 | 'properties': self.get_property_descriptions(), 78 | 'actions': self.actions, 79 | 'events': self.events, 80 | 'links': self.links, 81 | 'baseHref': self.base_href, 82 | 'pin': { 83 | 'required': self.pin_required, 84 | 'pattern': self.pin_pattern, 85 | }, 86 | 'credentialsRequired': self.credentials_required, 87 | } 88 | 89 | if self.description: 90 | thing['description'] = self.description 91 | 92 | return thing 93 | 94 | def get_id(self): 95 | """ 96 | Get the ID of the device. 97 | 98 | Returns the ID as a string. 99 | """ 100 | return self.id 101 | 102 | def get_title(self): 103 | """ 104 | Get the title of the device. 105 | 106 | Returns the title as a string. 107 | """ 108 | if hasattr(self, 'name') and not self.title: 109 | self.title = self.name 110 | 111 | return self.title 112 | 113 | def get_property_descriptions(self): 114 | """ 115 | Get the device's properties as a dictionary. 116 | 117 | Returns the properties as a dictionary, i.e. name -> description. 118 | """ 119 | return {k: v.as_property_description() 120 | for k, v in self.properties.items()} 121 | 122 | def find_property(self, property_name): 123 | """ 124 | Find a property by name. 125 | 126 | property_name -- the property to find 127 | 128 | Returns a Property object, if found, else None. 129 | """ 130 | return self.properties.get(property_name, None) 131 | 132 | def get_property(self, property_name): 133 | """ 134 | Get a property's value. 135 | 136 | property_name -- the property to get the value of 137 | 138 | Returns the properties value, if found, else None. 139 | """ 140 | prop = self.find_property(property_name) 141 | if prop: 142 | return prop.get_value() 143 | 144 | return None 145 | 146 | def has_property(self, property_name): 147 | """ 148 | Determine whether or not this device has a given property. 149 | 150 | property_name -- the property to look for 151 | 152 | Returns a boolean, indicating whether or not the device has the 153 | property. 154 | """ 155 | return property_name in self.properties 156 | 157 | def notify_property_changed(self, prop): 158 | """ 159 | Notify the AddonManager in the Gateway that a device property changed. 160 | 161 | prop -- the property that changed 162 | """ 163 | self.adapter.manager_proxy.send_property_changed_notification(prop) 164 | 165 | def action_notify(self, action): 166 | """ 167 | Notify the AddonManager in the Gateway that an action's status changed. 168 | 169 | action -- the action whose status changed 170 | """ 171 | self.adapter.manager_proxy.send_action_status_notification(action) 172 | 173 | def event_notify(self, event): 174 | """ 175 | Notify the AddonManager in the Gateway that an event occurred. 176 | 177 | event -- the event that occurred 178 | """ 179 | self.adapter.manager_proxy.send_event_notification(event) 180 | 181 | def connected_notify(self, connected): 182 | """ 183 | Notify the AddonManager in the Gateway of the device's connectivity. 184 | 185 | connected -- whether or not the device is connected 186 | """ 187 | self.adapter.manager_proxy.send_connected_notification(self, connected) 188 | 189 | def set_property(self, property_name, value): 190 | """ 191 | Set a property value. 192 | 193 | property_name -- name of the property to set 194 | value -- value to set 195 | """ 196 | prop = self.find_property(property_name) 197 | if not prop: 198 | return 199 | 200 | prop.set_value(value) 201 | 202 | def request_action(self, action_id, action_name, action_input): 203 | """ 204 | Request that a new action be performed. 205 | 206 | action_id -- ID of the new action 207 | action_name -- name of the action 208 | action_input -- any inputs to the action 209 | """ 210 | if action_name not in self.actions: 211 | return 212 | 213 | # Validate action input, if present. 214 | metadata = self.actions[action_name] 215 | if 'input' in metadata: 216 | try: 217 | validate(action_input, metadata['input']) 218 | except ValidationError: 219 | return 220 | 221 | action = Action(action_id, self, action_name, action_input) 222 | self.perform_action(action) 223 | 224 | def remove_action(self, action_id, action_name): 225 | """ 226 | Remove an existing action. 227 | 228 | action_id -- ID of the action 229 | action_name -- name of the action 230 | """ 231 | if action_name not in self.actions: 232 | return 233 | 234 | self.cancel_action(action_id, action_name) 235 | 236 | def perform_action(self, action): 237 | """ 238 | Do anything necessary to perform the given action. 239 | 240 | action -- the action to perform 241 | """ 242 | pass 243 | 244 | def cancel_action(self, action_id, action_name): 245 | """ 246 | Do anything necessary to cancel the given action. 247 | 248 | action_id -- ID of the action 249 | action_name -- name of the action 250 | """ 251 | pass 252 | 253 | def add_action(self, name, metadata): 254 | """ 255 | Add an action. 256 | 257 | name -- name of the action 258 | metadata -- action metadata, i.e. type, description, etc., as a dict 259 | """ 260 | if not metadata: 261 | metadata = {} 262 | 263 | if 'href' in metadata: 264 | del metadata['href'] 265 | 266 | self.actions[name] = metadata 267 | 268 | def add_event(self, name, metadata): 269 | """ 270 | Add an event. 271 | 272 | name -- name of the event 273 | metadata -- event metadata, i.e. type, description, etc., as a dict 274 | """ 275 | if not metadata: 276 | metadata = {} 277 | 278 | if 'href' in metadata: 279 | del metadata['href'] 280 | 281 | self.events[name] = metadata 282 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /gateway_addon/addon_manager_proxy.py: -------------------------------------------------------------------------------- 1 | """Proxy for sending messages between the Gateway and an add-on.""" 2 | 3 | from __future__ import print_function 4 | from singleton_decorator import singleton 5 | import functools 6 | import json 7 | import threading 8 | import time 9 | import websocket 10 | 11 | from .api_handler_utils import APIRequest, APIResponse 12 | from .constants import MessageType 13 | from .errors import (ActionError, APIHandlerError, NotifyError, PropertyError, 14 | SetCredentialsError, SetPinError) 15 | from .ipc import IpcClient 16 | 17 | print = functools.partial(print, flush=True) 18 | 19 | 20 | @singleton 21 | class AddonManagerProxy: 22 | """ 23 | Proxy for communicating with the Gateway's AddonManager. 24 | 25 | This proxy interprets all of the required incoming message types that need 26 | to be handled by add-ons and sends back responses as appropriate. 27 | """ 28 | 29 | def __init__(self, plugin_id, verbose=False): 30 | """ 31 | Initialize the object. 32 | 33 | plugin_id -- ID of this plugin 34 | verbose -- whether or not to enable verbose logging 35 | """ 36 | self.adapters = {} 37 | self.notifiers = {} 38 | self.api_handlers = {} 39 | self.ipc_client = IpcClient( 40 | plugin_id, 41 | self.on_message, 42 | verbose=verbose 43 | ) 44 | self.gateway_version = self.ipc_client.gateway_version 45 | self.user_profile = self.ipc_client.user_profile 46 | self.preferences = self.ipc_client.preferences 47 | self.plugin_id = plugin_id 48 | self.verbose = verbose 49 | self.running = True 50 | 51 | def close(self): 52 | """Close the proxy.""" 53 | try: 54 | self.ipc_client.close() 55 | except websocket.WebSocketException: 56 | pass 57 | 58 | self.running = False 59 | 60 | def send_error(self, message): 61 | """ 62 | Send an error notification. 63 | 64 | message -- error message 65 | """ 66 | self.send(MessageType.PLUGIN_ERROR_NOTIFICATION, {'message': message}) 67 | 68 | def add_adapter(self, adapter): 69 | """ 70 | Send a notification that an adapter has been added. 71 | 72 | adapter -- the Adapter that was added 73 | """ 74 | if self.verbose: 75 | print('AddonManagerProxy: add_adapter:', adapter.id) 76 | 77 | self.adapters[adapter.id] = adapter 78 | self.send(MessageType.ADAPTER_ADDED_NOTIFICATION, { 79 | 'adapterId': adapter.id, 80 | 'name': adapter.name, 81 | 'packageName': adapter.package_name, 82 | }) 83 | 84 | def add_notifier(self, notifier): 85 | """ 86 | Send a notification that a notifier has been added. 87 | 88 | notifier -- the Notifier that was added 89 | """ 90 | if self.verbose: 91 | print('AddonManagerProxy: add_notifier:', notifier.id) 92 | 93 | self.notifiers[notifier.id] = notifier 94 | self.send(MessageType.NOTIFIER_ADDED_NOTIFICATION, { 95 | 'notifierId': notifier.id, 96 | 'name': notifier.name, 97 | 'packageName': notifier.package_name, 98 | }) 99 | 100 | def add_api_handler(self, handler): 101 | """ 102 | Send a notification that an API handler has been added. 103 | 104 | handler -- the handler that was added 105 | """ 106 | if self.verbose: 107 | print('AddonManagerProxy: add_api_handler:', handler.package_name) 108 | 109 | self.api_handlers[handler.package_name] = handler 110 | self.send(MessageType.API_HANDLER_ADDED_NOTIFICATION, { 111 | 'packageName': handler.package_name, 112 | }) 113 | 114 | def handle_device_added(self, device): 115 | """ 116 | Send a notification that a new device has been added. 117 | 118 | device -- the Device that was added 119 | """ 120 | if self.verbose: 121 | print('AddonManagerProxy: handle_device_added:', device.id) 122 | 123 | data = { 124 | 'adapterId': device.adapter.id, 125 | 'device': device.as_dict(), 126 | } 127 | self.send(MessageType.DEVICE_ADDED_NOTIFICATION, data) 128 | 129 | def handle_device_removed(self, device): 130 | """ 131 | Send a notification that a managed device was removed. 132 | 133 | device -- the Device that was removed 134 | """ 135 | if self.verbose: 136 | print('AddonManagerProxy: handle_device_removed:', device.id) 137 | 138 | self.send(MessageType.ADAPTER_REMOVE_DEVICE_RESPONSE, { 139 | 'adapterId': device.adapter.id, 140 | 'deviceId': device.id, 141 | }) 142 | 143 | def handle_outlet_added(self, outlet): 144 | """ 145 | Send a notification that a new outlet has been added. 146 | 147 | outlet -- the Outlet that was added 148 | """ 149 | if self.verbose: 150 | print('AddonManagerProxy: handle_outlet_added:', outlet.id) 151 | 152 | data = { 153 | 'notifierId': outlet.notifier.id, 154 | 'outlet': outlet.as_dict(), 155 | } 156 | self.send(MessageType.OUTLET_ADDED_NOTIFICATION, data) 157 | 158 | def handle_outlet_removed(self, outlet): 159 | """ 160 | Send a notification that a managed outlet was removed. 161 | 162 | outlet -- the Outlet that was removed 163 | """ 164 | if self.verbose: 165 | print('AddonManagerProxy: handle_outlet_removed:', outlet.id) 166 | 167 | self.send(MessageType.OUTLET_REMOVED_NOTIFICATION, { 168 | 'notifierId': outlet.notifier.id, 169 | 'outletId': outlet.id, 170 | }) 171 | 172 | def send_pairing_prompt(self, adapter, prompt, url=None, device=None): 173 | """ 174 | Send a prompt to the UI notifying the user to take some action. 175 | 176 | adapter -- The adapter sending the prompt 177 | prompt -- The prompt to send 178 | url -- URL to site with further explanation or troubleshooting info 179 | device -- Device the prompt is associated with 180 | """ 181 | data = { 182 | 'adapterId': adapter.id, 183 | 'prompt': prompt, 184 | } 185 | 186 | if url is not None: 187 | data['url'] = url 188 | 189 | if device is not None: 190 | data['deviceId'] = device.id 191 | 192 | self.send(MessageType.ADAPTER_PAIRING_PROMPT_NOTIFICATION, data) 193 | 194 | def send_unpairing_prompt(self, adapter, prompt, url=None, device=None): 195 | """ 196 | Send a prompt to the UI notifying the user to take some action. 197 | 198 | adapter -- The adapter sending the prompt 199 | prompt -- The prompt to send 200 | url -- URL to site with further explanation or troubleshooting info 201 | device -- Device the prompt is associated with 202 | """ 203 | data = { 204 | 'adapterId': adapter.id, 205 | 'prompt': prompt, 206 | } 207 | 208 | if url is not None: 209 | data['url'] = url 210 | 211 | if device is not None: 212 | data['deviceId'] = device.id 213 | 214 | self.send(MessageType.ADAPTER_UNPAIRING_PROMPT_NOTIFICATION, data) 215 | 216 | def send_property_changed_notification(self, prop): 217 | """ 218 | Send a notification that a device property changed. 219 | 220 | prop -- the Property that changed 221 | """ 222 | self.send(MessageType.DEVICE_PROPERTY_CHANGED_NOTIFICATION, { 223 | 'adapterId': prop.device.adapter.id, 224 | 'deviceId': prop.device.id, 225 | 'property': prop.as_dict(), 226 | }) 227 | 228 | def send_action_status_notification(self, action): 229 | """ 230 | Send a notification that an action's status changed. 231 | 232 | action -- the action whose status changed 233 | """ 234 | self.send(MessageType.DEVICE_ACTION_STATUS_NOTIFICATION, { 235 | 'adapterId': action.device.adapter.id, 236 | 'deviceId': action.device.id, 237 | 'action': action.as_dict(), 238 | }) 239 | 240 | def send_event_notification(self, event): 241 | """ 242 | Send a notification that an event occurred. 243 | 244 | event -- the event that occurred 245 | """ 246 | self.send(MessageType.DEVICE_EVENT_NOTIFICATION, { 247 | 'adapterId': event.device.adapter.id, 248 | 'deviceId': event.device.id, 249 | 'event': event.as_dict(), 250 | }) 251 | 252 | def send_connected_notification(self, device, connected): 253 | """ 254 | Send a notification that a device's connectivity state changed. 255 | 256 | device -- the device object 257 | connected -- the new connectivity state 258 | """ 259 | self.send(MessageType.DEVICE_CONNECTED_STATE_NOTIFICATION, { 260 | 'adapterId': device.adapter.id, 261 | 'deviceId': device.id, 262 | 'connected': connected, 263 | }) 264 | 265 | def send(self, msg_type, data): 266 | """ 267 | Send a message through the IPC socket. 268 | 269 | msg_type -- the message type 270 | data -- the data to send, as a dictionary 271 | """ 272 | if data is None: 273 | data = {} 274 | 275 | data['pluginId'] = self.plugin_id 276 | 277 | try: 278 | self.ipc_client.ws.send(json.dumps({ 279 | 'messageType': msg_type, 280 | 'data': data, 281 | })) 282 | except websocket.WebSocketException as e: 283 | print('AddonManagerProxy: Failed to send message: {}'.format(e)) 284 | 285 | def on_message(self, msg): 286 | """Read a message from the IPC socket.""" 287 | if self.verbose: 288 | print('AddonManagerProxy: recv:', msg) 289 | 290 | msg_type = msg['messageType'] 291 | 292 | if msg_type == MessageType.PLUGIN_UNLOAD_REQUEST: 293 | self.send(MessageType.PLUGIN_UNLOAD_RESPONSE, {}) 294 | 295 | def close_fn(proxy): 296 | # Give the message above time to be sent and received. 297 | time.sleep(.5) 298 | proxy.close() 299 | 300 | self.make_thread(close_fn, args=(self,)) 301 | return 302 | 303 | if 'data' not in msg: 304 | print('AddonManagerProxy: data not present in message') 305 | return 306 | 307 | if msg_type == MessageType.API_HANDLER_UNLOAD_REQUEST: 308 | package_name = msg['data']['packageName'] 309 | if package_name not in self.api_handlers: 310 | print('AddonManager: Unrecognized handler, ignoring ' 311 | 'message.') 312 | return 313 | 314 | handler = self.api_handlers[package_name] 315 | 316 | def unload_fn(proxy, handler): 317 | handler.unload() 318 | proxy.send(MessageType.API_HANDLER_UNLOAD_RESPONSE, 319 | {'packageName': handler.package_name}) 320 | 321 | self.make_thread(unload_fn, args=(self, handler)) 322 | del self.api_handlers[handler.package_name] 323 | return 324 | 325 | if msg_type == MessageType.API_HANDLER_API_REQUEST: 326 | package_name = msg['data']['packageName'] 327 | if package_name not in self.api_handlers: 328 | print('AddonManager: Unrecognized handler, ignoring ' 329 | 'message.') 330 | return 331 | 332 | handler = self.api_handlers[package_name] 333 | 334 | def request_fn(proxy, handler): 335 | message_id = msg['data']['messageId'] 336 | 337 | try: 338 | request = APIRequest(**msg['data']['request']) 339 | response = handler.handle_request(request) 340 | 341 | proxy.send(MessageType.API_HANDLER_API_RESPONSE, { 342 | 'packageName': package_name, 343 | 'messageId': message_id, 344 | 'response': response.to_json() 345 | }) 346 | except APIHandlerError as e: 347 | proxy.send(MessageType.API_HANDLER_API_RESPONSE, { 348 | 'packageName': package_name, 349 | 'messageId': message_id, 350 | 'response': APIResponse( 351 | status=500, 352 | content_type='text/plain', 353 | content=str(e), 354 | ).to_json(), 355 | }) 356 | 357 | self.make_thread(request_fn, args=(self, handler)) 358 | return 359 | 360 | if 'notifierId' in msg['data']: 361 | notifier_id = msg['data']['notifierId'] 362 | if notifier_id not in self.notifiers: 363 | print('AddonManagerProxy: Unrecognized notifier, ignoring ' 364 | 'message.') 365 | return 366 | 367 | notifier = self.notifiers[notifier_id] 368 | 369 | if msg_type == MessageType.NOTIFIER_UNLOAD_REQUEST: 370 | def unload_fn(proxy, notifier): 371 | notifier.unload() 372 | proxy.send(MessageType.NOTIFIER_UNLOAD_RESPONSE, 373 | {'notifierId': notifier.id}) 374 | 375 | self.make_thread(unload_fn, args=(self, notifier)) 376 | del self.notifiers[notifier.id] 377 | return 378 | 379 | if msg_type == MessageType.OUTLET_NOTIFY_REQUEST: 380 | def notify_fn(proxy, notifier): 381 | outlet_id = msg['data']['outletId'] 382 | outlet = notifier.get_outlet(outlet_id) 383 | if outlet is None: 384 | print('AddonManagerProxy: No such outlet, ' 385 | 'ignoring message.') 386 | return 387 | 388 | message_id = msg['data']['messageId'] 389 | 390 | try: 391 | outlet.notify(msg['data']['title'], 392 | msg['data']['message'], 393 | msg['data']['level']) 394 | 395 | proxy.send(MessageType.OUTLET_NOTIFY_RESPONSE, { 396 | 'notifierId': notifier_id, 397 | 'outletId': outlet_id, 398 | 'messageId': message_id, 399 | 'success': True, 400 | }) 401 | except NotifyError: 402 | proxy.send(MessageType.OUTLET_NOTIFY_RESPONSE, { 403 | 'notifierId': notifier_id, 404 | 'outletId': outlet_id, 405 | 'messageId': message_id, 406 | 'success': False, 407 | }) 408 | 409 | self.make_thread(notify_fn, args=(self, notifier)) 410 | return 411 | 412 | return 413 | 414 | if 'adapterId' not in msg['data']: 415 | print('AddonManagerProxy: Adapter ID not present in message.') 416 | return 417 | 418 | adapter_id = msg['data']['adapterId'] 419 | if adapter_id not in self.adapters: 420 | print('AddonManagerProxy: Unrecognized adapter, ignoring ' 421 | 'message.') 422 | return 423 | 424 | adapter = self.adapters[adapter_id] 425 | 426 | # High-level adapter messages 427 | if msg_type == MessageType.ADAPTER_START_PAIRING_COMMAND: 428 | self.make_thread(adapter.start_pairing, 429 | args=(msg['data']['timeout'],)) 430 | return 431 | 432 | if msg_type == MessageType.ADAPTER_CANCEL_PAIRING_COMMAND: 433 | self.make_thread(adapter.cancel_pairing) 434 | return 435 | 436 | if msg_type == MessageType.ADAPTER_UNLOAD_REQUEST: 437 | def unload_fn(proxy, adapter): 438 | adapter.unload() 439 | proxy.send(MessageType.ADAPTER_UNLOAD_RESPONSE, 440 | {'adapterId': adapter.id}) 441 | 442 | self.make_thread(unload_fn, args=(self, adapter)) 443 | del self.adapters[adapter.id] 444 | return 445 | 446 | if msg_type == MessageType.DEVICE_SAVED_NOTIFICATION: 447 | self.make_thread( 448 | adapter.handle_device_saved, 449 | args=(msg['data']['deviceId'], msg['data']['device']) 450 | ) 451 | return 452 | 453 | # All messages from here on are assumed to require a valid deviceId 454 | if 'data' not in msg or 'deviceId' not in msg['data']: 455 | print('AddonManagerProxy: No deviceId present in message, ' 456 | 'ignoring.') 457 | return 458 | 459 | device_id = msg['data']['deviceId'] 460 | if msg_type == MessageType.ADAPTER_REMOVE_DEVICE_REQUEST: 461 | self.make_thread(adapter.remove_thing, args=(device_id,)) 462 | return 463 | 464 | if msg_type == MessageType.ADAPTER_CANCEL_REMOVE_DEVICE_COMMAND: 465 | self.make_thread(adapter.cancel_remove_thing, 466 | args=(device_id,)) 467 | return 468 | 469 | if msg_type == MessageType.DEVICE_SET_PROPERTY_COMMAND: 470 | def set_prop_fn(proxy, adapter): 471 | dev = adapter.get_device(device_id) 472 | if not dev: 473 | return 474 | 475 | prop = dev.find_property(msg['data']['propertyName']) 476 | if not prop: 477 | return 478 | 479 | try: 480 | prop.set_value(msg['data']['propertyValue']) 481 | if prop.fire_and_forget: 482 | proxy.send_property_changed_notification(prop) 483 | except PropertyError: 484 | proxy.send_property_changed_notification(prop) 485 | 486 | self.make_thread(set_prop_fn, args=(self, adapter)) 487 | return 488 | 489 | if msg_type == MessageType.DEVICE_REQUEST_ACTION_REQUEST: 490 | def request_action_fn(proxy, adapter): 491 | action_id = msg['data']['actionId'] 492 | action_name = msg['data']['actionName'] 493 | 494 | try: 495 | dev = adapter.get_device(device_id) 496 | 497 | if dev: 498 | action_input = None 499 | if 'input' in msg['data']: 500 | action_input = msg['data']['input'] 501 | 502 | dev.request_action(action_id, 503 | action_name, 504 | action_input) 505 | 506 | proxy.send( 507 | MessageType.DEVICE_REQUEST_ACTION_RESPONSE, 508 | { 509 | 'adapterId': adapter_id, 510 | 'deviceId': device_id, 511 | 'actionName': action_name, 512 | 'actionId': action_id, 513 | 'success': True, 514 | } 515 | ) 516 | except ActionError: 517 | proxy.send( 518 | MessageType.DEVICE_REQUEST_ACTION_RESPONSE, 519 | { 520 | 'adapterId': adapter_id, 521 | 'deviceId': device_id, 522 | 'actionName': action_name, 523 | 'actionId': action_id, 524 | 'success': False, 525 | } 526 | ) 527 | 528 | self.make_thread(request_action_fn, args=(self, adapter)) 529 | return 530 | 531 | if msg_type == MessageType.DEVICE_REMOVE_ACTION_REQUEST: 532 | def remove_action_fn(proxy, adapter): 533 | action_id = msg['data']['actionId'] 534 | action_name = msg['data']['actionName'] 535 | message_id = msg['data']['messageId'] 536 | 537 | try: 538 | dev = adapter.get_device(device_id) 539 | 540 | if dev: 541 | dev.remove_action(action_id, action_name) 542 | 543 | proxy.send(MessageType.DEVICE_REMOVE_ACTION_RESPONSE, { 544 | 'adapterId': adapter_id, 545 | 'actionName': action_name, 546 | 'actionId': action_id, 547 | 'messageId': message_id, 548 | 'deviceId': device_id, 549 | 'success': True, 550 | }) 551 | except ActionError: 552 | proxy.send(MessageType.DEVICE_REMOVE_ACTION_RESPONSE, { 553 | 'adapterId': adapter_id, 554 | 'actionName': action_name, 555 | 'actionId': action_id, 556 | 'messageId': message_id, 557 | 'deviceId': device_id, 558 | 'success': False, 559 | }) 560 | 561 | self.make_thread(remove_action_fn, args=(self, adapter)) 562 | return 563 | 564 | if msg_type == MessageType.DEVICE_SET_PIN_REQUEST: 565 | def set_pin_fn(proxy, adapter): 566 | message_id = msg['data']['messageId'] 567 | 568 | try: 569 | adapter.set_pin(device_id, msg['data']['pin']) 570 | 571 | dev = adapter.get_device(device_id) 572 | proxy.send(MessageType.DEVICE_SET_PIN_RESPONSE, { 573 | 'device': dev.as_dict(), 574 | 'messageId': message_id, 575 | 'adapterId': adapter.id, 576 | 'success': True, 577 | }) 578 | except SetPinError: 579 | proxy.send(MessageType.DEVICE_SET_PIN_RESPONSE, { 580 | 'deviceId': device_id, 581 | 'messageId': message_id, 582 | 'adapterId': adapter.id, 583 | 'success': False, 584 | }) 585 | 586 | self.make_thread(set_pin_fn, args=(self, adapter)) 587 | return 588 | 589 | if msg_type == MessageType.DEVICE_SET_CREDENTIALS_REQUEST: 590 | def set_credentials_fn(proxy, adapter): 591 | message_id = msg['data']['messageId'] 592 | 593 | try: 594 | adapter.set_credentials(device_id, 595 | msg['data']['username'], 596 | msg['data']['password']) 597 | 598 | dev = adapter.get_device(device_id) 599 | proxy.send( 600 | MessageType.DEVICE_SET_CREDENTIALS_RESPONSE, 601 | { 602 | 'device': dev.as_dict(), 603 | 'messageId': message_id, 604 | 'adapterId': adapter.id, 605 | 'success': True, 606 | } 607 | ) 608 | except SetCredentialsError: 609 | proxy.send( 610 | MessageType.DEVICE_SET_CREDENTIALS_RESPONSE, 611 | { 612 | 'deviceId': device_id, 613 | 'messageId': message_id, 614 | 'adapterId': adapter.id, 615 | 'success': False, 616 | } 617 | ) 618 | 619 | self.make_thread(set_credentials_fn, args=(self, adapter)) 620 | return 621 | 622 | @staticmethod 623 | def make_thread(target, args=()): 624 | """ 625 | Start up a thread in the background. 626 | 627 | target -- the target function 628 | args -- arguments to pass to target 629 | """ 630 | t = threading.Thread(target=target, args=args) 631 | t.daemon = True 632 | t.start() 633 | --------------------------------------------------------------------------------