├── MANIFEST.in ├── requirements.txt ├── package.sh ├── .gitignore ├── webthing ├── errors.py ├── __init__.py ├── subscriber.py ├── event.py ├── utils.py ├── value.py ├── action.py ├── property.py ├── thing.py └── server.py ├── .github └── workflows │ ├── projects.yml │ ├── build.yml │ └── release.yml ├── test.sh ├── CODE_OF_CONDUCT.md ├── setup.py ├── CHANGELOG.md ├── example ├── single-thing.py └── multiple-things.py ├── README.rst └── LICENSE.txt /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ifaddr>=0.1.0 2 | jsonschema>=3.2.0 3 | pyee>=8.1.0 4 | tornado>=6.1.0 5 | zeroconf>=0.28.0 6 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | rm -rf build/ dist/ 4 | 5 | python3 setup.py bdist_wheel 6 | python3 setup.py sdist 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info/ 3 | *.py[cod] 4 | *.swp 5 | *~ 6 | /build/ 7 | /dist/ 8 | /requirements.txt 9 | __pycache__/ 10 | -------------------------------------------------------------------------------- /webthing/errors.py: -------------------------------------------------------------------------------- 1 | """Exception types.""" 2 | 3 | 4 | class PropertyError(Exception): 5 | """Exception to indicate an issue with a property.""" 6 | 7 | pass 8 | -------------------------------------------------------------------------------- /webthing/__init__.py: -------------------------------------------------------------------------------- 1 | """This module provides a high-level interface for creating a Web Thing.""" 2 | 3 | # flake8: noqa 4 | from .action import Action 5 | from .event import Event 6 | from .property import Property 7 | from .server import MultipleThings, SingleThing, WebThingServer 8 | from .subscriber import Subscriber 9 | from .thing import Thing 10 | from .value import Value 11 | -------------------------------------------------------------------------------- /.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/4 16 | GITHUB_PROJECT_COLUMN_NAME: To do 17 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # run flake8 and pydocstyle 4 | pip install flake8 pydocstyle 5 | flake8 webthing 6 | pydocstyle webthing 7 | 8 | # clone the webthing-tester 9 | git clone https://github.com/WebThingsIO/webthing-tester 10 | pip install -r webthing-tester/requirements.txt 11 | 12 | # build and test the single-thing example 13 | PYTHONPATH=. python example/single-thing.py & 14 | EXAMPLE_PID=$! 15 | sleep 5 16 | python ./webthing-tester/test-client.py 17 | kill -15 $EXAMPLE_PID 18 | 19 | # build and test the multiple-things example 20 | PYTHONPATH=. python example/multiple-things.py & 21 | EXAMPLE_PID=$! 22 | sleep 5 23 | python ./webthing-tester/test-client.py --path-prefix "/0" 24 | kill -15 $EXAMPLE_PID 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webthing/subscriber.py: -------------------------------------------------------------------------------- 1 | """High-level Subscriber base class implementation.""" 2 | 3 | 4 | class Subscriber: 5 | """Abstract Subscriber class.""" 6 | 7 | def update_property(self, property_): 8 | """ 9 | Send an update about a Property. 10 | 11 | :param property_: Property 12 | """ 13 | raise NotImplementedError 14 | 15 | def update_action(self, action): 16 | """ 17 | Send an update about an Action. 18 | 19 | :param action: Action 20 | """ 21 | raise NotImplementedError 22 | 23 | def update_event(self, event): 24 | """ 25 | Send an update about an Event. 26 | 27 | :param event: Event 28 | """ 29 | raise NotImplementedError 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 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: [ 17 | 3.5, 18 | 3.6, 19 | 3.7, 20 | 3.8, 21 | 3.9, 22 | ] 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install -r requirements.txt 32 | - name: Lint with flake8 33 | run: | 34 | pip install flake8 35 | flake8 webthing --count --max-line-length=79 --statistics 36 | - name: Test with pytest 37 | run: | 38 | ./test.sh 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+ 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.9 16 | - name: Set release version 17 | run: echo "RELEASE_VERSION=${GITHUB_REF:11}" >> $GITHUB_ENV 18 | - name: Create Release 19 | id: create_release 20 | uses: actions/create-release@v1.0.0 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | tag_name: ${{ github.ref }} 25 | release_name: Release ${{ env.RELEASE_VERSION }} 26 | draft: false 27 | prerelease: false 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install twine wheel 32 | - name: Publish to PyPI 33 | run: | 34 | ./package.sh 35 | twine upload --username ${{ secrets.PYPI_USERNAME }} --password ${{ secrets.PYPI_PASSWORD }} dist/* 36 | -------------------------------------------------------------------------------- /webthing/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 thing.""" 8 | 9 | def __init__(self, thing, name, data=None): 10 | """ 11 | Initialize the object. 12 | 13 | thing -- Thing this event belongs to 14 | name -- name of the event 15 | data -- data associated with the event 16 | """ 17 | self.thing = thing 18 | self.name = name 19 | self.data = data 20 | self.time = 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 | self.name: { 30 | 'timestamp': self.time, 31 | }, 32 | } 33 | 34 | if self.data is not None: 35 | description[self.name]['data'] = self.data 36 | 37 | return description 38 | 39 | def get_thing(self): 40 | """Get the thing associated with this event.""" 41 | return self.thing 42 | 43 | def get_name(self): 44 | """Get the event's name.""" 45 | return self.name 46 | 47 | def get_data(self): 48 | """Get the event's data.""" 49 | return self.data 50 | 51 | def get_time(self): 52 | """Get the event's timestamp.""" 53 | return self.time 54 | -------------------------------------------------------------------------------- /webthing/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | 3 | import datetime 4 | import ifaddr 5 | import socket 6 | 7 | 8 | def timestamp(): 9 | """ 10 | Get the current time. 11 | 12 | Returns the current time in the form YYYY-mm-ddTHH:MM:SS+00:00 13 | """ 14 | return datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S+00:00') 15 | 16 | 17 | def get_ip(): 18 | """ 19 | Get the default local IP address. 20 | 21 | From: https://stackoverflow.com/a/28950776 22 | """ 23 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 24 | try: 25 | s.connect(('10.255.255.255', 1)) 26 | ip = s.getsockname()[0] 27 | except (socket.error, IndexError): 28 | ip = '127.0.0.1' 29 | finally: 30 | s.close() 31 | 32 | return ip 33 | 34 | 35 | def get_addresses(): 36 | """ 37 | Get all IP addresses. 38 | 39 | Returns list of addresses. 40 | """ 41 | addresses = set() 42 | 43 | for iface in ifaddr.get_adapters(): 44 | for addr in iface.ips: 45 | # Filter out link-local addresses. 46 | if addr.is_IPv4: 47 | ip = addr.ip 48 | 49 | if not ip.startswith('169.254.'): 50 | addresses.add(ip) 51 | elif addr.is_IPv6: 52 | # Sometimes, IPv6 addresses will have the interface name 53 | # appended, e.g. %eth0. Handle that. 54 | ip = addr.ip[0].split('%')[0].lower() 55 | 56 | if not ip.startswith('fe80:'): 57 | addresses.add('[{}]'.format(ip)) 58 | 59 | return sorted(list(addresses)) 60 | -------------------------------------------------------------------------------- /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 sys 7 | 8 | 9 | here = path.abspath(path.dirname(__file__)) 10 | 11 | # Get the long description from the README file. 12 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 13 | long_description = f.read() 14 | 15 | setup( 16 | name='webthing', 17 | version='0.15.0', 18 | description='HTTP Web Thing implementation', 19 | long_description=long_description, 20 | url='https://github.com/WebThingsIO/webthing-python', 21 | author='WebThingsIO', 22 | author_email='team@webthings.io', 23 | keywords='iot web thing webthing', 24 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 25 | install_requires=[ 26 | 'ifaddr>=0.1.0', 27 | 'jsonschema>=3.2.0', 28 | 'pyee>=8.1.0', 29 | 'tornado>=6.1.0', 30 | 'zeroconf>=0.28.0', 31 | ], 32 | classifiers=[ 33 | 'Development Status :: 4 - Beta', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 36 | 'Programming Language :: Python :: 3.5', 37 | 'Programming Language :: Python :: 3.6', 38 | 'Programming Language :: Python :: 3.7', 39 | 'Programming Language :: Python :: 3.8', 40 | 'Programming Language :: Python :: 3.9', 41 | ], 42 | license='MPL-2.0', 43 | project_urls={ 44 | 'Source': 'https://github.com/WebThingsIO/webthing-python', 45 | 'Tracker': 'https://github.com/WebThingsIO/webthing-python/issues', 46 | }, 47 | python_requires='>=3.5, <4', 48 | ) 49 | -------------------------------------------------------------------------------- /webthing/value.py: -------------------------------------------------------------------------------- 1 | """An observable, settable value interface.""" 2 | 3 | from pyee import EventEmitter 4 | 5 | 6 | class Value(EventEmitter): 7 | """ 8 | A property value. 9 | 10 | This is used for communicating between the Thing representation and the 11 | actual physical thing implementation. 12 | 13 | Notifies all observers when the underlying value changes through an 14 | external update (command to turn the light off) or if the underlying sensor 15 | reports a new value. 16 | """ 17 | 18 | def __init__(self, initial_value, value_forwarder=None): 19 | """ 20 | Initialize the object. 21 | 22 | initial_value -- the initial value 23 | value_forwarder -- the method that updates the actual value on the 24 | thing 25 | """ 26 | EventEmitter.__init__(self) 27 | self.last_value = initial_value 28 | self.value_forwarder = value_forwarder 29 | 30 | def set(self, value): 31 | """ 32 | Set a new value for this thing. 33 | 34 | value -- value to set 35 | """ 36 | if self.value_forwarder is not None: 37 | self.value_forwarder(value) 38 | 39 | self.notify_of_external_update(value) 40 | 41 | def get(self): 42 | """Return the last known value from the underlying thing.""" 43 | return self.last_value 44 | 45 | def notify_of_external_update(self, value): 46 | """ 47 | Notify observers of a new value. 48 | 49 | value -- new value 50 | """ 51 | if value is not None and value != self.last_value: 52 | self.last_value = value 53 | self.emit('update', value) 54 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # webthing Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.15.0] - 2021-01-02 6 | ### Added 7 | - Parameter to disable host validation in server. 8 | 9 | ## [0.14.0] - 2020-09-23 10 | ### Changed 11 | - Dropped Python 2.7 support. 12 | - Update author and URLs to indicate new project home. 13 | 14 | ## [0.13.2] - 2020-06-18 15 | ### Changed 16 | - mDNS record now indicates TLS support. 17 | 18 | ## [0.13.1] - 2020-05-29 19 | ### Changed 20 | - Support new zeroconf version 21 | 22 | ## [0.13.0] - 2020-05-04 23 | ### Changed 24 | - Invalid POST requests to action resources now generate an error status. 25 | - Python 3.4 support dropped 26 | 27 | ## [0.12.2] - 2020-03-27 28 | ### Added 29 | - Support OPTIONS requests to allow for CORS. 30 | 31 | ## [0.12.1] - 2020-01-13 32 | ### Changed 33 | - Abstracted WebSocket subscriber class to allow library to work with other web frameworks. 34 | 35 | ## [0.12.0] - 2019-07-12 36 | ### Changed 37 | - Things now use `title` rather than `name`. 38 | - Things now require a unique ID in the form of a URI. 39 | ### Added 40 | - Ability to set a base URL path on server. 41 | - Support for `id`, `base`, `security`, and `securityDefinitions` keys in thing description. 42 | 43 | ## [0.11.3] - 2019-04-10 44 | ### Changed 45 | - Simpler dependencies with no native requirements. 46 | 47 | ## [0.11.2] - 2019-03-11 48 | ### Added 49 | - Support for Tornado 6.x 50 | 51 | ## [0.11.1] - 2019-01-28 52 | ### Added 53 | - Support for Python 2.7 and 3.4 54 | 55 | ## [0.11.0] - 2019-01-16 56 | ### Changed 57 | - WebThingServer constructor can now take a list of additional API routes. 58 | ### Fixed 59 | - Properties could not include a custom `links` array at initialization. 60 | 61 | ## [0.10.0] - 2018-11-30 62 | ### Changed 63 | - Property, Action, and Event description now use `links` rather than `href`. - [Spec PR](https://github.com/WebThingsIO/wot/pull/119) 64 | 65 | [Unreleased]: https://github.com/WebThingsIO/webthing-python/compare/v0.15.0...HEAD 66 | [0.15.0]: https://github.com/WebThingsIO/webthing-python/compare/v0.14.0...v0.15.0 67 | [0.14.0]: https://github.com/WebThingsIO/webthing-python/compare/v0.13.2...v0.14.0 68 | [0.13.2]: https://github.com/WebThingsIO/webthing-python/compare/v0.13.1...v0.13.2 69 | [0.13.1]: https://github.com/WebThingsIO/webthing-python/compare/v0.13.0...v0.13.1 70 | [0.13.0]: https://github.com/WebThingsIO/webthing-python/compare/v0.12.2...v0.13.0 71 | [0.12.2]: https://github.com/WebThingsIO/webthing-python/compare/v0.12.1...v0.12.2 72 | [0.12.1]: https://github.com/WebThingsIO/webthing-python/compare/v0.12.0...v0.12.1 73 | [0.12.0]: https://github.com/WebThingsIO/webthing-python/compare/v0.11.3...v0.12.0 74 | [0.11.3]: https://github.com/WebThingsIO/webthing-python/compare/v0.11.2...v0.11.3 75 | [0.11.2]: https://github.com/WebThingsIO/webthing-python/compare/v0.11.1...v0.11.2 76 | [0.11.1]: https://github.com/WebThingsIO/webthing-python/compare/v0.11.0...v0.11.1 77 | [0.11.0]: https://github.com/WebThingsIO/webthing-python/compare/v0.10.0...v0.11.0 78 | [0.10.0]: https://github.com/WebThingsIO/webthing-python/compare/v0.9.2...v0.10.0 79 | -------------------------------------------------------------------------------- /webthing/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 thing.""" 8 | 9 | def __init__(self, id_, thing, name, input_): 10 | """ 11 | Initialize the object. 12 | 13 | id_ ID of this action 14 | thing -- the Thing this action belongs to 15 | name -- name of the action 16 | input_ -- any action inputs 17 | """ 18 | self.id = id_ 19 | self.thing = thing 20 | self.name = name 21 | self.input = input_ 22 | self.href_prefix = '' 23 | self.href = '/actions/{}/{}'.format(self.name, self.id) 24 | self.status = 'created' 25 | self.time_requested = timestamp() 26 | self.time_completed = None 27 | 28 | def as_action_description(self): 29 | """ 30 | Get the action description. 31 | 32 | Returns a dictionary describing the action. 33 | """ 34 | description = { 35 | self.name: { 36 | 'href': self.href_prefix + self.href, 37 | 'timeRequested': self.time_requested, 38 | 'status': self.status, 39 | }, 40 | } 41 | 42 | if self.input is not None: 43 | description[self.name]['input'] = self.input 44 | 45 | if self.time_completed is not None: 46 | description[self.name]['timeCompleted'] = self.time_completed 47 | 48 | return description 49 | 50 | def set_href_prefix(self, prefix): 51 | """ 52 | Set the prefix of any hrefs associated with this action. 53 | 54 | prefix -- the prefix 55 | """ 56 | self.href_prefix = prefix 57 | 58 | def get_id(self): 59 | """Get this action's ID.""" 60 | return self.id 61 | 62 | def get_name(self): 63 | """Get this action's name.""" 64 | return self.name 65 | 66 | def get_href(self): 67 | """Get this action's href.""" 68 | return self.href_prefix + self.href 69 | 70 | def get_status(self): 71 | """Get this action's status.""" 72 | return self.status 73 | 74 | def get_thing(self): 75 | """Get the thing associated with this action.""" 76 | return self.thing 77 | 78 | def get_time_requested(self): 79 | """Get the time the action was requested.""" 80 | return self.time_requested 81 | 82 | def get_time_completed(self): 83 | """Get the time the action was completed.""" 84 | return self.time_completed 85 | 86 | def get_input(self): 87 | """Get the inputs for this action.""" 88 | return self.input 89 | 90 | def start(self): 91 | """Start performing the action.""" 92 | self.status = 'pending' 93 | self.thing.action_notify(self) 94 | self.perform_action() 95 | self.finish() 96 | 97 | def perform_action(self): 98 | """Override this with the code necessary to perform the action.""" 99 | pass 100 | 101 | def cancel(self): 102 | """Override this with the code necessary to cancel the action.""" 103 | pass 104 | 105 | def finish(self): 106 | """Finish performing the action.""" 107 | self.status = 'completed' 108 | self.time_completed = timestamp() 109 | self.thing.action_notify(self) 110 | -------------------------------------------------------------------------------- /webthing/property.py: -------------------------------------------------------------------------------- 1 | """High-level Property base class implementation.""" 2 | 3 | from copy import deepcopy 4 | from jsonschema import validate 5 | from jsonschema.exceptions import ValidationError 6 | 7 | from .errors import PropertyError 8 | 9 | 10 | class Property: 11 | """A Property represents an individual state value of a thing.""" 12 | 13 | def __init__(self, thing, name, value, metadata=None): 14 | """ 15 | Initialize the object. 16 | 17 | thing -- the Thing this property belongs to 18 | name -- name of the property 19 | value -- Value object to hold the property value 20 | metadata -- property metadata, i.e. type, description, unit, etc., 21 | as a dict 22 | """ 23 | self.thing = thing 24 | self.name = name 25 | self.value = value 26 | self.href_prefix = '' 27 | self.href = '/properties/{}'.format(self.name) 28 | self.metadata = metadata if metadata is not None else {} 29 | 30 | # Add the property change observer to notify the Thing about a property 31 | # change. 32 | self.value.on('update', lambda _: self.thing.property_notify(self)) 33 | 34 | def validate_value(self, value): 35 | """ 36 | Validate new property value before setting it. 37 | 38 | value -- New value 39 | """ 40 | if 'readOnly' in self.metadata and self.metadata['readOnly']: 41 | raise PropertyError('Read-only property') 42 | 43 | try: 44 | validate(value, self.metadata) 45 | except ValidationError: 46 | raise PropertyError('Invalid property value') 47 | 48 | def as_property_description(self): 49 | """ 50 | Get the property description. 51 | 52 | Returns a dictionary describing the property. 53 | """ 54 | description = deepcopy(self.metadata) 55 | 56 | if 'links' not in description: 57 | description['links'] = [] 58 | 59 | description['links'].append( 60 | { 61 | 'rel': 'property', 62 | 'href': self.href_prefix + self.href, 63 | } 64 | ) 65 | return description 66 | 67 | def set_href_prefix(self, prefix): 68 | """ 69 | Set the prefix of any hrefs associated with this property. 70 | 71 | prefix -- the prefix 72 | """ 73 | self.href_prefix = prefix 74 | 75 | def get_href(self): 76 | """ 77 | Get the href of this property. 78 | 79 | Returns the href. 80 | """ 81 | return self.href_prefix + self.href 82 | 83 | def get_value(self): 84 | """ 85 | Get the current property value. 86 | 87 | Returns the value. 88 | """ 89 | return self.value.get() 90 | 91 | def set_value(self, value): 92 | """ 93 | Set the current value of the property. 94 | 95 | value -- the value to set 96 | """ 97 | self.validate_value(value) 98 | self.value.set(value) 99 | 100 | def get_name(self): 101 | """ 102 | Get the name of this property. 103 | 104 | Returns the name. 105 | """ 106 | return self.name 107 | 108 | def get_thing(self): 109 | """Get the thing associated with this property.""" 110 | return self.thing 111 | 112 | def get_metadata(self): 113 | """Get the metadata associated with this property.""" 114 | return self.metadata 115 | -------------------------------------------------------------------------------- /example/single-thing.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from webthing import (Action, Event, Property, SingleThing, Thing, Value, 3 | WebThingServer) 4 | import logging 5 | import time 6 | import uuid 7 | 8 | 9 | class OverheatedEvent(Event): 10 | 11 | def __init__(self, thing, data): 12 | Event.__init__(self, thing, 'overheated', data=data) 13 | 14 | 15 | class FadeAction(Action): 16 | 17 | def __init__(self, thing, input_): 18 | Action.__init__(self, uuid.uuid4().hex, thing, 'fade', input_=input_) 19 | 20 | def perform_action(self): 21 | time.sleep(self.input['duration'] / 1000) 22 | self.thing.set_property('brightness', self.input['brightness']) 23 | self.thing.add_event(OverheatedEvent(self.thing, 102)) 24 | 25 | 26 | def make_thing(): 27 | thing = Thing( 28 | 'urn:dev:ops:my-lamp-1234', 29 | 'My Lamp', 30 | ['OnOffSwitch', 'Light'], 31 | 'A web connected lamp' 32 | ) 33 | 34 | thing.add_property( 35 | Property(thing, 36 | 'on', 37 | Value(True), 38 | metadata={ 39 | '@type': 'OnOffProperty', 40 | 'title': 'On/Off', 41 | 'type': 'boolean', 42 | 'description': 'Whether the lamp is turned on', 43 | })) 44 | thing.add_property( 45 | Property(thing, 46 | 'brightness', 47 | Value(50), 48 | metadata={ 49 | '@type': 'BrightnessProperty', 50 | 'title': 'Brightness', 51 | 'type': 'integer', 52 | 'description': 'The level of light from 0-100', 53 | 'minimum': 0, 54 | 'maximum': 100, 55 | 'unit': 'percent', 56 | })) 57 | 58 | thing.add_available_action( 59 | 'fade', 60 | { 61 | 'title': 'Fade', 62 | 'description': 'Fade the lamp to a given level', 63 | 'input': { 64 | 'type': 'object', 65 | 'required': [ 66 | 'brightness', 67 | 'duration', 68 | ], 69 | 'properties': { 70 | 'brightness': { 71 | 'type': 'integer', 72 | 'minimum': 0, 73 | 'maximum': 100, 74 | 'unit': 'percent', 75 | }, 76 | 'duration': { 77 | 'type': 'integer', 78 | 'minimum': 1, 79 | 'unit': 'milliseconds', 80 | }, 81 | }, 82 | }, 83 | }, 84 | FadeAction) 85 | 86 | thing.add_available_event( 87 | 'overheated', 88 | { 89 | 'description': 90 | 'The lamp has exceeded its safe operating temperature', 91 | 'type': 'number', 92 | 'unit': 'degree celsius', 93 | }) 94 | 95 | return thing 96 | 97 | 98 | def run_server(): 99 | thing = make_thing() 100 | 101 | # If adding more than one thing, use MultipleThings() with a name. 102 | # In the single thing case, the thing's name will be broadcast. 103 | server = WebThingServer(SingleThing(thing), port=8888) 104 | try: 105 | logging.info('starting the server') 106 | server.start() 107 | except KeyboardInterrupt: 108 | logging.info('stopping the server') 109 | server.stop() 110 | logging.info('done') 111 | 112 | 113 | if __name__ == '__main__': 114 | logging.basicConfig( 115 | level=10, 116 | format="%(asctime)s %(filename)s:%(lineno)s %(levelname)s %(message)s" 117 | ) 118 | run_server() 119 | -------------------------------------------------------------------------------- /example/multiple-things.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, print_function 2 | from webthing import (Action, Event, MultipleThings, Property, Thing, Value, 3 | WebThingServer) 4 | import logging 5 | import random 6 | import time 7 | import tornado.ioloop 8 | import uuid 9 | 10 | 11 | class OverheatedEvent(Event): 12 | 13 | def __init__(self, thing, data): 14 | Event.__init__(self, thing, 'overheated', data=data) 15 | 16 | 17 | class FadeAction(Action): 18 | 19 | def __init__(self, thing, input_): 20 | Action.__init__(self, uuid.uuid4().hex, thing, 'fade', input_=input_) 21 | 22 | def perform_action(self): 23 | time.sleep(self.input['duration'] / 1000) 24 | self.thing.set_property('brightness', self.input['brightness']) 25 | self.thing.add_event(OverheatedEvent(self.thing, 102)) 26 | 27 | 28 | class ExampleDimmableLight(Thing): 29 | """A dimmable light that logs received commands to stdout.""" 30 | 31 | def __init__(self): 32 | Thing.__init__( 33 | self, 34 | 'urn:dev:ops:my-lamp-1234', 35 | 'My Lamp', 36 | ['OnOffSwitch', 'Light'], 37 | 'A web connected lamp' 38 | ) 39 | 40 | self.add_property( 41 | Property(self, 42 | 'on', 43 | Value(True, lambda v: print('On-State is now', v)), 44 | metadata={ 45 | '@type': 'OnOffProperty', 46 | 'title': 'On/Off', 47 | 'type': 'boolean', 48 | 'description': 'Whether the lamp is turned on', 49 | })) 50 | 51 | self.add_property( 52 | Property(self, 53 | 'brightness', 54 | Value(50, lambda v: print('Brightness is now', v)), 55 | metadata={ 56 | '@type': 'BrightnessProperty', 57 | 'title': 'Brightness', 58 | 'type': 'integer', 59 | 'description': 'The level of light from 0-100', 60 | 'minimum': 0, 61 | 'maximum': 100, 62 | 'unit': 'percent', 63 | })) 64 | 65 | self.add_available_action( 66 | 'fade', 67 | { 68 | 'title': 'Fade', 69 | 'description': 'Fade the lamp to a given level', 70 | 'input': { 71 | 'type': 'object', 72 | 'required': [ 73 | 'brightness', 74 | 'duration', 75 | ], 76 | 'properties': { 77 | 'brightness': { 78 | 'type': 'integer', 79 | 'minimum': 0, 80 | 'maximum': 100, 81 | 'unit': 'percent', 82 | }, 83 | 'duration': { 84 | 'type': 'integer', 85 | 'minimum': 1, 86 | 'unit': 'milliseconds', 87 | }, 88 | }, 89 | }, 90 | }, 91 | FadeAction) 92 | 93 | self.add_available_event( 94 | 'overheated', 95 | { 96 | 'description': 97 | 'The lamp has exceeded its safe operating temperature', 98 | 'type': 'number', 99 | 'unit': 'degree celsius', 100 | }) 101 | 102 | 103 | class FakeGpioHumiditySensor(Thing): 104 | """A humidity sensor which updates its measurement every few seconds.""" 105 | 106 | def __init__(self): 107 | Thing.__init__( 108 | self, 109 | 'urn:dev:ops:my-humidity-sensor-1234', 110 | 'My Humidity Sensor', 111 | ['MultiLevelSensor'], 112 | 'A web connected humidity sensor' 113 | ) 114 | 115 | self.level = Value(0.0) 116 | self.add_property( 117 | Property(self, 118 | 'level', 119 | self.level, 120 | metadata={ 121 | '@type': 'LevelProperty', 122 | 'title': 'Humidity', 123 | 'type': 'number', 124 | 'description': 'The current humidity in %', 125 | 'minimum': 0, 126 | 'maximum': 100, 127 | 'unit': 'percent', 128 | 'readOnly': True, 129 | })) 130 | 131 | logging.debug('starting the sensor update looping task') 132 | self.timer = tornado.ioloop.PeriodicCallback( 133 | self.update_level, 134 | 3000 135 | ) 136 | self.timer.start() 137 | 138 | def update_level(self): 139 | new_level = self.read_from_gpio() 140 | logging.debug('setting new humidity level: %s', new_level) 141 | self.level.notify_of_external_update(new_level) 142 | 143 | def cancel_update_level_task(self): 144 | self.timer.stop() 145 | 146 | @staticmethod 147 | def read_from_gpio(): 148 | """Mimic an actual sensor updating its reading every couple seconds.""" 149 | return abs(70.0 * random.random() * (-0.5 + random.random())) 150 | 151 | 152 | def run_server(): 153 | # Create a thing that represents a dimmable light 154 | light = ExampleDimmableLight() 155 | 156 | # Create a thing that represents a humidity sensor 157 | sensor = FakeGpioHumiditySensor() 158 | 159 | # If adding more than one thing, use MultipleThings() with a name. 160 | # In the single thing case, the thing's name will be broadcast. 161 | server = WebThingServer(MultipleThings([light, sensor], 162 | 'LightAndTempDevice'), 163 | port=8888) 164 | try: 165 | logging.info('starting the server') 166 | server.start() 167 | except KeyboardInterrupt: 168 | logging.debug('canceling the sensor update looping task') 169 | sensor.cancel_update_level_task() 170 | logging.info('stopping the server') 171 | server.stop() 172 | logging.info('done') 173 | 174 | 175 | if __name__ == '__main__': 176 | logging.basicConfig( 177 | level=10, 178 | format="%(asctime)s %(filename)s:%(lineno)s %(levelname)s %(message)s" 179 | ) 180 | run_server() 181 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | webthing 2 | ======== 3 | 4 | .. image:: https://github.com/WebThingsIO/webthing-python/workflows/Python%20package/badge.svg 5 | :target: https://github.com/WebThingsIO/webthing-python/workflows/Python%20package 6 | .. image:: https://img.shields.io/pypi/v/webthing.svg 7 | :target: https://pypi.org/project/webthing/ 8 | .. image:: https://img.shields.io/badge/license-MPL--2.0-blue.svg 9 | :target: https://github.com/WebThingsIO/webthing-python/blob/master/LICENSE.txt 10 | 11 | Implementation of an HTTP `Web Thing `_. This library is compatible with Python 2.7 and 3.5+. 12 | 13 | Installation 14 | ============ 15 | 16 | ``webthing`` can be installed via ``pip``, as such: 17 | 18 | .. code:: shell 19 | 20 | $ pip install webthing 21 | 22 | Running the Sample 23 | ================== 24 | 25 | .. code:: shell 26 | 27 | $ wget https://raw.githubusercontent.com/WebThingsIO/webthing-python/master/example/single-thing.py 28 | $ python3 single-thing.py 29 | 30 | This starts a server and lets you search for it from your gateway through mDNS. To add it to your gateway, navigate to the Things page in the gateway's UI and click the + icon at the bottom right. If both are on the same network, the example thing will automatically appear. 31 | 32 | Example Implementation 33 | ====================== 34 | 35 | In this code-walkthrough we will set up a dimmable light and a humidity sensor (both using fake data, of course). Both working examples can be found in the `examples directory `_. 36 | 37 | Dimmable Light 38 | -------------- 39 | 40 | Imagine you have a dimmable light that you want to expose via the web of things API. The light can be turned on/off and the brightness can be set from 0% to 100%. Besides the name, description, and type, a |Light|_ is required to expose two properties: 41 | 42 | .. |Light| replace:: ``Light`` 43 | .. _Light: https://webthings.io/schemas/#Light 44 | 45 | * ``on``: the state of the light, whether it is turned on or off 46 | 47 | - Setting this property via a ``PUT {"on": true/false}`` call to the REST API toggles the light. 48 | 49 | * ``brightness``: the brightness level of the light from 0-100% 50 | 51 | - Setting this property via a PUT call to the REST API sets the brightness level of this light. 52 | 53 | First we create a new Thing: 54 | 55 | .. code:: python 56 | 57 | light = Thing( 58 | 'urn:dev:ops:my-lamp-1234', 59 | 'My Lamp', 60 | ['OnOffSwitch', 'Light'], 61 | 'A web connected lamp' 62 | ) 63 | 64 | Now we can add the required properties. 65 | 66 | The ``on`` property reports and sets the on/off state of the light. For this, we need to have a ``Value`` object which holds the actual state and also a method to turn the light on/off. For our purposes, we just want to log the new state if the light is switched on/off. 67 | 68 | .. code:: python 69 | 70 | light.add_property( 71 | Property( 72 | light, 73 | 'on', 74 | Value(True, lambda v: print('On-State is now', v)), 75 | metadata={ 76 | '@type': 'OnOffProperty', 77 | 'title': 'On/Off', 78 | 'type': 'boolean', 79 | 'description': 'Whether the lamp is turned on', 80 | })) 81 | 82 | The ``brightness`` property reports the brightness level of the light and sets the level. Like before, instead of actually setting the level of a light, we just log the level. 83 | 84 | .. code:: python 85 | 86 | light.add_property( 87 | Property( 88 | light, 89 | 'brightness', 90 | Value(50, lambda v: print('Brightness is now', v)), 91 | metadata={ 92 | '@type': 'BrightnessProperty', 93 | 'title': 'Brightness', 94 | 'type': 'number', 95 | 'description': 'The level of light from 0-100', 96 | 'minimum': 0, 97 | 'maximum': 100, 98 | 'unit': 'percent', 99 | })) 100 | 101 | Now we can add our newly created thing to the server and start it: 102 | 103 | .. code:: python 104 | 105 | # If adding more than one thing, use MultipleThings() with a name. 106 | # In the single thing case, the thing's name will be broadcast. 107 | server = WebThingServer(SingleThing(light), port=8888) 108 | 109 | try: 110 | server.start() 111 | except KeyboardInterrupt: 112 | server.stop() 113 | 114 | This will start the server, making the light available via the WoT REST API and announcing it as a discoverable resource on your local network via mDNS. 115 | 116 | Sensor 117 | ------ 118 | 119 | Let's now also connect a humidity sensor to the server we set up for our light. 120 | 121 | A |MultiLevelSensor|_ (a sensor that returns a level instead of just on/off) has one required property (besides the name, type, and optional description): ``level``. We want to monitor this property and get notified if the value changes. 122 | 123 | .. |MultiLevelSensor| replace:: ``MultiLevelSensor`` 124 | .. _MultiLevelSensor: https://webthings.io/schemas/#MultiLevelSensor 125 | 126 | First we create a new Thing: 127 | 128 | .. code:: python 129 | 130 | sensor = Thing( 131 | 'urn:dev:ops:my-humidity-sensor-1234', 132 | 'My Humidity Sensor', 133 | ['MultiLevelSensor'], 134 | 'A web connected humidity sensor' 135 | ) 136 | 137 | Then we create and add the appropriate property: 138 | 139 | * ``level``: tells us what the sensor is actually reading 140 | 141 | - Contrary to the light, the value cannot be set via an API call, as it wouldn't make much sense, to SET what a sensor is reading. Therefore, we are creating a **readOnly** property. 142 | 143 | .. code:: python 144 | 145 | level = Value(0.0); 146 | 147 | sensor.add_property( 148 | Property( 149 | sensor, 150 | 'level', 151 | level, 152 | metadata={ 153 | '@type': 'LevelProperty', 154 | 'title': 'Humidity', 155 | 'type': 'number', 156 | 'description': 'The current humidity in %', 157 | 'minimum': 0, 158 | 'maximum': 100, 159 | 'unit': 'percent', 160 | 'readOnly': True, 161 | })) 162 | 163 | Now we have a sensor that constantly reports 0%. To make it usable, we need a thread or some kind of input when the sensor has a new reading available. For this purpose we start a thread that queries the physical sensor every few seconds. For our purposes, it just calls a fake method. 164 | 165 | .. code:: python 166 | 167 | self.sensor_update_task = \ 168 | get_event_loop().create_task(self.update_level()) 169 | 170 | async def update_level(self): 171 | try: 172 | while True: 173 | await sleep(3) 174 | new_level = self.read_from_gpio() 175 | logging.debug('setting new humidity level: %s', new_level) 176 | self.level.notify_of_external_update(new_level) 177 | except CancelledError: 178 | pass 179 | 180 | This will update our ``Value`` object with the sensor readings via the ``self.level.notify_of_external_update(read_from_gpio())`` call. The ``Value`` object now notifies the property and the thing that the value has changed, which in turn notifies all websocket listeners. 181 | 182 | Adding to Gateway 183 | ================= 184 | 185 | To add your web thing to the WebThings Gateway, install the "Web Thing" add-on and follow the instructions `here `_. 186 | -------------------------------------------------------------------------------- /webthing/thing.py: -------------------------------------------------------------------------------- 1 | """High-level Thing base class implementation.""" 2 | 3 | from jsonschema import validate 4 | from jsonschema.exceptions import ValidationError 5 | 6 | 7 | class Thing: 8 | """A Web Thing.""" 9 | 10 | def __init__(self, id_, title, type_=[], description=''): 11 | """ 12 | Initialize the object. 13 | 14 | id_ -- the thing's unique ID - must be a URI 15 | title -- the thing's title 16 | type_ -- the thing's type(s) 17 | description -- description of the thing 18 | """ 19 | if not isinstance(type_, list): 20 | type_ = [type_] 21 | 22 | self.id = id_ 23 | self.context = 'https://webthings.io/schemas' 24 | self.type = type_ 25 | self.title = title 26 | self.description = description 27 | self.properties = {} 28 | self.available_actions = {} 29 | self.available_events = {} 30 | self.actions = {} 31 | self.events = [] 32 | self.subscribers = set() 33 | self.href_prefix = '' 34 | self.ui_href = None 35 | 36 | def as_thing_description(self): 37 | """ 38 | Return the thing state as a Thing Description. 39 | 40 | Returns the state as a dictionary. 41 | """ 42 | thing = { 43 | 'id': self.id, 44 | 'title': self.title, 45 | '@context': self.context, 46 | 'properties': self.get_property_descriptions(), 47 | 'actions': {}, 48 | 'events': {}, 49 | 'links': [ 50 | { 51 | 'rel': 'properties', 52 | 'href': '{}/properties'.format(self.href_prefix), 53 | }, 54 | { 55 | 'rel': 'actions', 56 | 'href': '{}/actions'.format(self.href_prefix), 57 | }, 58 | { 59 | 'rel': 'events', 60 | 'href': '{}/events'.format(self.href_prefix), 61 | }, 62 | ], 63 | } 64 | 65 | for name, action in self.available_actions.items(): 66 | thing['actions'][name] = action['metadata'] 67 | thing['actions'][name]['links'] = [ 68 | { 69 | 'rel': 'action', 70 | 'href': '{}/actions/{}'.format(self.href_prefix, name), 71 | }, 72 | ] 73 | 74 | for name, event in self.available_events.items(): 75 | thing['events'][name] = event['metadata'] 76 | thing['events'][name]['links'] = [ 77 | { 78 | 'rel': 'event', 79 | 'href': '{}/events/{}'.format(self.href_prefix, name), 80 | }, 81 | ] 82 | 83 | if self.ui_href is not None: 84 | thing['links'].append({ 85 | 'rel': 'alternate', 86 | 'mediaType': 'text/html', 87 | 'href': self.ui_href, 88 | }) 89 | 90 | if self.description: 91 | thing['description'] = self.description 92 | 93 | if self.type: 94 | thing['@type'] = self.type 95 | 96 | return thing 97 | 98 | def get_href(self): 99 | """Get this thing's href.""" 100 | if self.href_prefix: 101 | return self.href_prefix 102 | 103 | return '/' 104 | 105 | def get_ui_href(self): 106 | """Get the UI href.""" 107 | return self.ui_href 108 | 109 | def set_href_prefix(self, prefix): 110 | """ 111 | Set the prefix of any hrefs associated with this thing. 112 | 113 | prefix -- the prefix 114 | """ 115 | self.href_prefix = prefix 116 | 117 | for property_ in self.properties.values(): 118 | property_.set_href_prefix(prefix) 119 | 120 | for action_name in self.actions.keys(): 121 | for action in self.actions[action_name]: 122 | action.set_href_prefix(prefix) 123 | 124 | def set_ui_href(self, href): 125 | """ 126 | Set the href of this thing's custom UI. 127 | 128 | href -- the href 129 | """ 130 | self.ui_href = href 131 | 132 | def get_id(self): 133 | """ 134 | Get the ID of the thing. 135 | 136 | Returns the ID as a string. 137 | """ 138 | return self.id 139 | 140 | def get_title(self): 141 | """ 142 | Get the title of the thing. 143 | 144 | Returns the title as a string. 145 | """ 146 | return self.title 147 | 148 | def get_context(self): 149 | """ 150 | Get the type context of the thing. 151 | 152 | Returns the context as a string. 153 | """ 154 | return self.context 155 | 156 | def get_type(self): 157 | """ 158 | Get the type(s) of the thing. 159 | 160 | Returns the list of types. 161 | """ 162 | return self.type 163 | 164 | def get_description(self): 165 | """ 166 | Get the description of the thing. 167 | 168 | Returns the description as a string. 169 | """ 170 | return self.description 171 | 172 | def get_property_descriptions(self): 173 | """ 174 | Get the thing's properties as a dictionary. 175 | 176 | Returns the properties as a dictionary, i.e. name -> description. 177 | """ 178 | return {k: v.as_property_description() 179 | for k, v in self.properties.items()} 180 | 181 | def get_action_descriptions(self, action_name=None): 182 | """ 183 | Get the thing's actions as an array. 184 | 185 | action_name -- Optional action name to get descriptions for 186 | 187 | Returns the action descriptions. 188 | """ 189 | descriptions = [] 190 | 191 | if action_name is None: 192 | for name in self.actions: 193 | for action in self.actions[name]: 194 | descriptions.append(action.as_action_description()) 195 | elif action_name in self.actions: 196 | for action in self.actions[action_name]: 197 | descriptions.append(action.as_action_description()) 198 | 199 | return descriptions 200 | 201 | def get_event_descriptions(self, event_name=None): 202 | """ 203 | Get the thing's events as an array. 204 | 205 | event_name -- Optional event name to get descriptions for 206 | 207 | Returns the event descriptions. 208 | """ 209 | if event_name is None: 210 | return [e.as_event_description() for e in self.events] 211 | else: 212 | return [e.as_event_description() 213 | for e in self.events if e.get_name() == event_name] 214 | 215 | def add_property(self, property_): 216 | """ 217 | Add a property to this thing. 218 | 219 | property_ -- property to add 220 | """ 221 | property_.set_href_prefix(self.href_prefix) 222 | self.properties[property_.name] = property_ 223 | 224 | def remove_property(self, property_): 225 | """ 226 | Remove a property from this thing. 227 | 228 | property_ -- property to remove 229 | """ 230 | if property_.name in self.properties: 231 | del self.properties[property_.name] 232 | 233 | def find_property(self, property_name): 234 | """ 235 | Find a property by name. 236 | 237 | property_name -- the property to find 238 | 239 | Returns a Property object, if found, else None. 240 | """ 241 | return self.properties.get(property_name, None) 242 | 243 | def get_property(self, property_name): 244 | """ 245 | Get a property's value. 246 | 247 | property_name -- the property to get the value of 248 | 249 | Returns the properties value, if found, else None. 250 | """ 251 | prop = self.find_property(property_name) 252 | if prop: 253 | return prop.get_value() 254 | 255 | return None 256 | 257 | def get_properties(self): 258 | """ 259 | Get a mapping of all properties and their values. 260 | 261 | Returns a dictionary of property_name -> value. 262 | """ 263 | return {prop.get_name(): prop.get_value() 264 | for prop in self.properties.values()} 265 | 266 | def has_property(self, property_name): 267 | """ 268 | Determine whether or not this thing has a given property. 269 | 270 | property_name -- the property to look for 271 | 272 | Returns a boolean, indicating whether or not the thing has the 273 | property. 274 | """ 275 | return property_name in self.properties 276 | 277 | def set_property(self, property_name, value): 278 | """ 279 | Set a property value. 280 | 281 | property_name -- name of the property to set 282 | value -- value to set 283 | """ 284 | prop = self.find_property(property_name) 285 | if not prop: 286 | return 287 | 288 | prop.set_value(value) 289 | 290 | def get_action(self, action_name, action_id): 291 | """ 292 | Get an action. 293 | 294 | action_name -- name of the action 295 | action_id -- ID of the action 296 | 297 | Returns the requested action if found, else None. 298 | """ 299 | if action_name not in self.actions: 300 | return None 301 | 302 | for action in self.actions[action_name]: 303 | if action.id == action_id: 304 | return action 305 | 306 | return None 307 | 308 | def add_event(self, event): 309 | """ 310 | Add a new event and notify subscribers. 311 | 312 | event -- the event that occurred 313 | """ 314 | self.events.append(event) 315 | self.event_notify(event) 316 | 317 | def add_available_event(self, name, metadata): 318 | """ 319 | Add an available event. 320 | 321 | name -- name of the event 322 | metadata -- event metadata, i.e. type, description, etc., as a dict 323 | """ 324 | if metadata is None: 325 | metadata = {} 326 | 327 | self.available_events[name] = { 328 | 'metadata': metadata, 329 | 'subscribers': set(), 330 | } 331 | 332 | def perform_action(self, action_name, input_=None): 333 | """ 334 | Perform an action on the thing. 335 | 336 | action_name -- name of the action 337 | input_ -- any action inputs 338 | 339 | Returns the action that was created. 340 | """ 341 | if action_name not in self.available_actions: 342 | return None 343 | 344 | action_type = self.available_actions[action_name] 345 | 346 | if 'input' in action_type['metadata']: 347 | try: 348 | validate(input_, action_type['metadata']['input']) 349 | except ValidationError: 350 | return None 351 | 352 | action = action_type['class'](self, input_=input_) 353 | action.set_href_prefix(self.href_prefix) 354 | self.action_notify(action) 355 | self.actions[action_name].append(action) 356 | return action 357 | 358 | def remove_action(self, action_name, action_id): 359 | """ 360 | Remove an existing action. 361 | 362 | action_name -- name of the action 363 | action_id -- ID of the action 364 | 365 | Returns a boolean indicating the presence of the action. 366 | """ 367 | action = self.get_action(action_name, action_id) 368 | if action is None: 369 | return False 370 | 371 | action.cancel() 372 | self.actions[action_name].remove(action) 373 | return True 374 | 375 | def add_available_action(self, name, metadata, cls): 376 | """ 377 | Add an available action. 378 | 379 | name -- name of the action 380 | metadata -- action metadata, i.e. type, description, etc., as a dict 381 | cls -- class to instantiate for this action 382 | """ 383 | if metadata is None: 384 | metadata = {} 385 | 386 | self.available_actions[name] = { 387 | 'metadata': metadata, 388 | 'class': cls, 389 | } 390 | self.actions[name] = [] 391 | 392 | def add_subscriber(self, subscriber): 393 | """ 394 | Add a new websocket subscriber. 395 | 396 | :param subscriber: Subscriber 397 | """ 398 | self.subscribers.add(subscriber) 399 | 400 | def remove_subscriber(self, subscriber): 401 | """ 402 | Remove a websocket subscriber. 403 | 404 | :param subscriber: Subscriber 405 | """ 406 | if subscriber in self.subscribers: 407 | self.subscribers.remove(subscriber) 408 | 409 | for name in self.available_events: 410 | self.remove_event_subscriber(name, subscriber) 411 | 412 | def add_event_subscriber(self, name, subscriber): 413 | """ 414 | Add a new websocket subscriber to an event. 415 | 416 | :param name: Name of the event 417 | :param subscriber: Subscriber 418 | """ 419 | if name in self.available_events: 420 | self.available_events[name]['subscribers'].add(subscriber) 421 | 422 | def remove_event_subscriber(self, name, subscriber): 423 | """ 424 | Remove a websocket subscriber from an event. 425 | 426 | :param name: Name of the event 427 | :param subscriber: Subscriber 428 | """ 429 | if name in self.available_events and \ 430 | subscriber in self.available_events[name]['subscribers']: 431 | self.available_events[name]['subscribers'].remove(subscriber) 432 | 433 | def property_notify(self, property_): 434 | """ 435 | Notify all subscribers of a property change. 436 | 437 | :param property_: the property that changed 438 | """ 439 | for subscriber in list(self.subscribers): 440 | subscriber.update_property(property_) 441 | 442 | def action_notify(self, action): 443 | """ 444 | Notify all subscribers of an action status change. 445 | 446 | :param action: The action whose status changed 447 | """ 448 | for subscriber in list(self.subscribers): 449 | subscriber.update_action(action) 450 | 451 | def event_notify(self, event): 452 | """ 453 | Notify all subscribers of an event. 454 | 455 | :param event: The event that occurred 456 | """ 457 | if event.name not in self.available_events: 458 | return 459 | 460 | for subscriber in self.available_events[event.name]['subscribers']: 461 | subscriber.update_event(event) 462 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webthing/server.py: -------------------------------------------------------------------------------- 1 | """Python Web Thing server implementation.""" 2 | 3 | from zeroconf import ServiceInfo, Zeroconf 4 | import json 5 | import socket 6 | import tornado.concurrent 7 | import tornado.gen 8 | import tornado.httpserver 9 | import tornado.ioloop 10 | import tornado.web 11 | import tornado.websocket 12 | 13 | from .errors import PropertyError 14 | from .subscriber import Subscriber 15 | from .utils import get_addresses, get_ip 16 | 17 | 18 | @tornado.gen.coroutine 19 | def perform_action(action): 20 | """Perform an Action in a coroutine.""" 21 | action.start() 22 | 23 | 24 | class SingleThing: 25 | """A container for a single thing.""" 26 | 27 | def __init__(self, thing): 28 | """ 29 | Initialize the container. 30 | 31 | thing -- the thing to store 32 | """ 33 | self.thing = thing 34 | 35 | def get_thing(self, _=None): 36 | """Get the thing at the given index.""" 37 | return self.thing 38 | 39 | def get_things(self): 40 | """Get the list of things.""" 41 | return [self.thing] 42 | 43 | def get_name(self): 44 | """Get the mDNS server name.""" 45 | return self.thing.title 46 | 47 | 48 | class MultipleThings: 49 | """A container for multiple things.""" 50 | 51 | def __init__(self, things, name): 52 | """ 53 | Initialize the container. 54 | 55 | things -- the things to store 56 | name -- the mDNS server name 57 | """ 58 | self.things = things 59 | self.name = name 60 | 61 | def get_thing(self, idx): 62 | """ 63 | Get the thing at the given index. 64 | 65 | idx -- the index 66 | """ 67 | try: 68 | idx = int(idx) 69 | except ValueError: 70 | return None 71 | 72 | if idx < 0 or idx >= len(self.things): 73 | return None 74 | 75 | return self.things[idx] 76 | 77 | def get_things(self): 78 | """Get the list of things.""" 79 | return self.things 80 | 81 | def get_name(self): 82 | """Get the mDNS server name.""" 83 | return self.name 84 | 85 | 86 | class BaseHandler(tornado.web.RequestHandler): 87 | """Base handler that is initialized with a thing.""" 88 | 89 | def initialize(self, things, hosts, disable_host_validation): 90 | """ 91 | Initialize the handler. 92 | 93 | things -- list of Things managed by this server 94 | hosts -- list of allowed hostnames 95 | disable_host_validation -- whether or not to disable host validation -- 96 | note that this can lead to DNS rebinding 97 | attacks 98 | """ 99 | self.things = things 100 | self.hosts = hosts 101 | self.disable_host_validation = disable_host_validation 102 | 103 | def prepare(self): 104 | """Validate Host header.""" 105 | host = self.request.headers.get('Host', None) 106 | if self.disable_host_validation or ( 107 | host is not None and host in self.hosts): 108 | return 109 | 110 | raise tornado.web.HTTPError(403) 111 | 112 | def get_thing(self, thing_id): 113 | """ 114 | Get the thing this request is for. 115 | 116 | thing_id -- ID of the thing to get, in string form 117 | 118 | Returns the thing, or None if not found. 119 | """ 120 | return self.things.get_thing(thing_id) 121 | 122 | def set_default_headers(self, *args, **kwargs): 123 | """Set the default headers for all requests.""" 124 | self.set_header('Access-Control-Allow-Origin', '*') 125 | self.set_header('Access-Control-Allow-Headers', 126 | 'Origin, X-Requested-With, Content-Type, Accept') 127 | self.set_header('Access-Control-Allow-Methods', 128 | 'GET, HEAD, PUT, POST, DELETE') 129 | 130 | def options(self, *args, **kwargs): 131 | """Handle an OPTIONS request.""" 132 | self.set_status(204) 133 | 134 | 135 | class ThingsHandler(BaseHandler): 136 | """Handle a request to / when the server manages multiple things.""" 137 | 138 | def get(self): 139 | """ 140 | Handle a GET request. 141 | 142 | property_name -- the name of the property from the URL path 143 | """ 144 | self.set_header('Content-Type', 'application/json') 145 | ws_href = '{}://{}'.format( 146 | 'wss' if self.request.protocol == 'https' else 'ws', 147 | self.request.headers.get('Host', '') 148 | ) 149 | 150 | descriptions = [] 151 | for thing in self.things.get_things(): 152 | description = thing.as_thing_description() 153 | description['href'] = thing.get_href() 154 | description['links'].append({ 155 | 'rel': 'alternate', 156 | 'href': '{}{}'.format(ws_href, thing.get_href()), 157 | }) 158 | description['base'] = '{}://{}{}'.format( 159 | self.request.protocol, 160 | self.request.headers.get('Host', ''), 161 | thing.get_href() 162 | ) 163 | description['securityDefinitions'] = { 164 | 'nosec_sc': { 165 | 'scheme': 'nosec', 166 | }, 167 | } 168 | description['security'] = 'nosec_sc' 169 | descriptions.append(description) 170 | 171 | self.write(json.dumps(descriptions)) 172 | 173 | 174 | class ThingHandler(tornado.websocket.WebSocketHandler, Subscriber): 175 | """Handle a request to /.""" 176 | 177 | def initialize(self, things, hosts, disable_host_validation): 178 | """ 179 | Initialize the handler. 180 | 181 | things -- list of Things managed by this server 182 | hosts -- list of allowed hostnames 183 | disable_host_validation -- whether or not to disable host validation -- 184 | note that this can lead to DNS rebinding 185 | attacks 186 | """ 187 | self.things = things 188 | self.hosts = hosts 189 | self.disable_host_validation = disable_host_validation 190 | 191 | def prepare(self): 192 | """Validate Host header.""" 193 | host = self.request.headers.get('Host', None) 194 | if self.disable_host_validation or ( 195 | host is not None and host in self.hosts): 196 | return 197 | 198 | raise tornado.web.HTTPError(403) 199 | 200 | def set_default_headers(self, *args, **kwargs): 201 | """Set the default headers for all requests.""" 202 | self.set_header('Access-Control-Allow-Origin', '*') 203 | self.set_header('Access-Control-Allow-Headers', 204 | 'Origin, X-Requested-With, Content-Type, Accept') 205 | self.set_header('Access-Control-Allow-Methods', 206 | 'GET, HEAD, PUT, POST, DELETE') 207 | 208 | def options(self, *args, **kwargs): 209 | """Handle an OPTIONS request.""" 210 | self.set_status(204) 211 | 212 | def get_thing(self, thing_id): 213 | """ 214 | Get the thing this request is for. 215 | 216 | thing_id -- ID of the thing to get, in string form 217 | 218 | Returns the thing, or None if not found. 219 | """ 220 | return self.things.get_thing(thing_id) 221 | 222 | @tornado.gen.coroutine 223 | def get(self, thing_id='0'): 224 | """ 225 | Handle a GET request, including websocket requests. 226 | 227 | thing_id -- ID of the thing this request is for 228 | """ 229 | self.thing = self.get_thing(thing_id) 230 | if self.thing is None: 231 | self.set_status(404) 232 | self.finish() 233 | return 234 | 235 | if self.request.headers.get('Upgrade', '').lower() == 'websocket': 236 | yield tornado.websocket.WebSocketHandler.get(self) 237 | return 238 | 239 | self.set_header('Content-Type', 'application/json') 240 | ws_href = '{}://{}'.format( 241 | 'wss' if self.request.protocol == 'https' else 'ws', 242 | self.request.headers.get('Host', '') 243 | ) 244 | 245 | description = self.thing.as_thing_description() 246 | description['links'].append({ 247 | 'rel': 'alternate', 248 | 'href': '{}{}'.format(ws_href, self.thing.get_href()), 249 | }) 250 | description['base'] = '{}://{}{}'.format( 251 | self.request.protocol, 252 | self.request.headers.get('Host', ''), 253 | self.thing.get_href() 254 | ) 255 | description['securityDefinitions'] = { 256 | 'nosec_sc': { 257 | 'scheme': 'nosec', 258 | }, 259 | } 260 | description['security'] = 'nosec_sc' 261 | 262 | self.write(json.dumps(description)) 263 | self.finish() 264 | 265 | def open(self): 266 | """Handle a new connection.""" 267 | self.thing.add_subscriber(self) 268 | 269 | def on_message(self, message): 270 | """ 271 | Handle an incoming message. 272 | 273 | message -- message to handle 274 | """ 275 | try: 276 | message = json.loads(message) 277 | except ValueError: 278 | try: 279 | self.write_message(json.dumps({ 280 | 'messageType': 'error', 281 | 'data': { 282 | 'status': '400 Bad Request', 283 | 'message': 'Parsing request failed', 284 | }, 285 | })) 286 | except tornado.websocket.WebSocketClosedError: 287 | pass 288 | 289 | return 290 | 291 | if 'messageType' not in message or 'data' not in message: 292 | try: 293 | self.write_message(json.dumps({ 294 | 'messageType': 'error', 295 | 'data': { 296 | 'status': '400 Bad Request', 297 | 'message': 'Invalid message', 298 | }, 299 | })) 300 | except tornado.websocket.WebSocketClosedError: 301 | pass 302 | 303 | return 304 | 305 | msg_type = message['messageType'] 306 | if msg_type == 'setProperty': 307 | for property_name, property_value in message['data'].items(): 308 | try: 309 | self.thing.set_property(property_name, property_value) 310 | except PropertyError as e: 311 | self.write_message(json.dumps({ 312 | 'messageType': 'error', 313 | 'data': { 314 | 'status': '400 Bad Request', 315 | 'message': str(e), 316 | }, 317 | })) 318 | elif msg_type == 'requestAction': 319 | for action_name, action_params in message['data'].items(): 320 | input_ = None 321 | if 'input' in action_params: 322 | input_ = action_params['input'] 323 | 324 | action = self.thing.perform_action(action_name, input_) 325 | if action: 326 | tornado.ioloop.IOLoop.current().spawn_callback( 327 | perform_action, 328 | action, 329 | ) 330 | else: 331 | self.write_message(json.dumps({ 332 | 'messageType': 'error', 333 | 'data': { 334 | 'status': '400 Bad Request', 335 | 'message': 'Invalid action request', 336 | 'request': message, 337 | }, 338 | })) 339 | elif msg_type == 'addEventSubscription': 340 | for event_name in message['data'].keys(): 341 | self.thing.add_event_subscriber(event_name, self) 342 | else: 343 | try: 344 | self.write_message(json.dumps({ 345 | 'messageType': 'error', 346 | 'data': { 347 | 'status': '400 Bad Request', 348 | 'message': 'Unknown messageType: ' + msg_type, 349 | 'request': message, 350 | }, 351 | })) 352 | except tornado.websocket.WebSocketClosedError: 353 | pass 354 | 355 | def on_close(self): 356 | """Handle a close event on the socket.""" 357 | self.thing.remove_subscriber(self) 358 | 359 | def check_origin(self, origin): 360 | """Allow connections from all origins.""" 361 | return True 362 | 363 | def update_property(self, property_): 364 | """ 365 | Send an update about a Property. 366 | 367 | :param property_: Property 368 | """ 369 | message = json.dumps({ 370 | 'messageType': 'propertyStatus', 371 | 'data': { 372 | property_.name: property_.get_value(), 373 | } 374 | }) 375 | 376 | self.write_message(message) 377 | 378 | def update_action(self, action): 379 | """ 380 | Send an update about an Action. 381 | 382 | :param action: Action 383 | """ 384 | message = json.dumps({ 385 | 'messageType': 'actionStatus', 386 | 'data': action.as_action_description(), 387 | }) 388 | 389 | self.write_message(message) 390 | 391 | def update_event(self, event): 392 | """ 393 | Send an update about an Event. 394 | 395 | :param event: Event 396 | """ 397 | message = json.dumps({ 398 | 'messageType': 'event', 399 | 'data': event.as_event_description(), 400 | }) 401 | 402 | self.write_message(message) 403 | 404 | 405 | class PropertiesHandler(BaseHandler): 406 | """Handle a request to /properties.""" 407 | 408 | def get(self, thing_id='0'): 409 | """ 410 | Handle a GET request. 411 | 412 | thing_id -- ID of the thing this request is for 413 | """ 414 | thing = self.get_thing(thing_id) 415 | if thing is None: 416 | self.set_status(404) 417 | return 418 | 419 | self.set_header('Content-Type', 'application/json') 420 | self.write(json.dumps(thing.get_properties())) 421 | 422 | 423 | class PropertyHandler(BaseHandler): 424 | """Handle a request to /properties/.""" 425 | 426 | def get(self, thing_id='0', property_name=None): 427 | """ 428 | Handle a GET request. 429 | 430 | thing_id -- ID of the thing this request is for 431 | property_name -- the name of the property from the URL path 432 | """ 433 | thing = self.get_thing(thing_id) 434 | if thing is None: 435 | self.set_status(404) 436 | return 437 | 438 | if thing.has_property(property_name): 439 | self.set_header('Content-Type', 'application/json') 440 | self.write(json.dumps({ 441 | property_name: thing.get_property(property_name), 442 | })) 443 | else: 444 | self.set_status(404) 445 | 446 | def put(self, thing_id='0', property_name=None): 447 | """ 448 | Handle a PUT request. 449 | 450 | thing_id -- ID of the thing this request is for 451 | property_name -- the name of the property from the URL path 452 | """ 453 | thing = self.get_thing(thing_id) 454 | if thing is None: 455 | self.set_status(404) 456 | return 457 | 458 | try: 459 | args = json.loads(self.request.body.decode()) 460 | except ValueError: 461 | self.set_status(400) 462 | return 463 | 464 | if property_name not in args: 465 | self.set_status(400) 466 | return 467 | 468 | if thing.has_property(property_name): 469 | try: 470 | thing.set_property(property_name, args[property_name]) 471 | except PropertyError: 472 | self.set_status(400) 473 | return 474 | 475 | self.set_header('Content-Type', 'application/json') 476 | self.write(json.dumps({ 477 | property_name: thing.get_property(property_name), 478 | })) 479 | else: 480 | self.set_status(404) 481 | 482 | 483 | class ActionsHandler(BaseHandler): 484 | """Handle a request to /actions.""" 485 | 486 | def get(self, thing_id='0'): 487 | """ 488 | Handle a GET request. 489 | 490 | thing_id -- ID of the thing this request is for 491 | """ 492 | thing = self.get_thing(thing_id) 493 | if thing is None: 494 | self.set_status(404) 495 | return 496 | 497 | self.set_header('Content-Type', 'application/json') 498 | self.write(json.dumps(thing.get_action_descriptions())) 499 | 500 | def post(self, thing_id='0'): 501 | """ 502 | Handle a POST request. 503 | 504 | thing_id -- ID of the thing this request is for 505 | """ 506 | thing = self.get_thing(thing_id) 507 | if thing is None: 508 | self.set_status(404) 509 | return 510 | 511 | try: 512 | message = json.loads(self.request.body.decode()) 513 | except ValueError: 514 | self.set_status(400) 515 | return 516 | 517 | keys = list(message.keys()) 518 | if len(keys) != 1: 519 | self.set_status(400) 520 | return 521 | 522 | action_name = keys[0] 523 | action_params = message[action_name] 524 | input_ = None 525 | if 'input' in action_params: 526 | input_ = action_params['input'] 527 | 528 | action = thing.perform_action(action_name, input_) 529 | if action: 530 | response = action.as_action_description() 531 | 532 | # Start the action 533 | tornado.ioloop.IOLoop.current().spawn_callback( 534 | perform_action, 535 | action, 536 | ) 537 | 538 | self.set_status(201) 539 | self.write(json.dumps(response)) 540 | else: 541 | self.set_status(400) 542 | 543 | 544 | class ActionHandler(BaseHandler): 545 | """Handle a request to /actions/.""" 546 | 547 | def get(self, thing_id='0', action_name=None): 548 | """ 549 | Handle a GET request. 550 | 551 | thing_id -- ID of the thing this request is for 552 | action_name -- name of the action from the URL path 553 | """ 554 | thing = self.get_thing(thing_id) 555 | if thing is None: 556 | self.set_status(404) 557 | return 558 | 559 | self.set_header('Content-Type', 'application/json') 560 | self.write(json.dumps(thing.get_action_descriptions( 561 | action_name=action_name))) 562 | 563 | def post(self, thing_id='0', action_name=None): 564 | """ 565 | Handle a POST request. 566 | 567 | thing_id -- ID of the thing this request is for 568 | """ 569 | thing = self.get_thing(thing_id) 570 | if thing is None: 571 | self.set_status(404) 572 | return 573 | 574 | try: 575 | message = json.loads(self.request.body.decode()) 576 | except ValueError: 577 | self.set_status(400) 578 | return 579 | 580 | keys = list(message.keys()) 581 | if len(keys) != 1: 582 | self.set_status(400) 583 | return 584 | 585 | if keys[0] != action_name: 586 | self.set_status(400) 587 | return 588 | 589 | action_params = message[action_name] 590 | input_ = None 591 | if 'input' in action_params: 592 | input_ = action_params['input'] 593 | 594 | action = thing.perform_action(action_name, input_) 595 | if action: 596 | response = action.as_action_description() 597 | 598 | # Start the action 599 | tornado.ioloop.IOLoop.current().spawn_callback( 600 | perform_action, 601 | action, 602 | ) 603 | 604 | self.set_status(201) 605 | self.write(json.dumps(response)) 606 | else: 607 | self.set_status(400) 608 | 609 | 610 | class ActionIDHandler(BaseHandler): 611 | """Handle a request to /actions//.""" 612 | 613 | def get(self, thing_id='0', action_name=None, action_id=None): 614 | """ 615 | Handle a GET request. 616 | 617 | thing_id -- ID of the thing this request is for 618 | action_name -- name of the action from the URL path 619 | action_id -- the action ID from the URL path 620 | """ 621 | thing = self.get_thing(thing_id) 622 | if thing is None: 623 | self.set_status(404) 624 | return 625 | 626 | action = thing.get_action(action_name, action_id) 627 | if action is None: 628 | self.set_status(404) 629 | return 630 | 631 | self.set_header('Content-Type', 'application/json') 632 | self.write(json.dumps(action.as_action_description())) 633 | 634 | def put(self, thing_id='0', action_name=None, action_id=None): 635 | """ 636 | Handle a PUT request. 637 | 638 | TODO: this is not yet defined in the spec 639 | 640 | thing_id -- ID of the thing this request is for 641 | action_name -- name of the action from the URL path 642 | action_id -- the action ID from the URL path 643 | """ 644 | thing = self.get_thing(thing_id) 645 | if thing is None: 646 | self.set_status(404) 647 | return 648 | 649 | self.set_status(200) 650 | 651 | def delete(self, thing_id='0', action_name=None, action_id=None): 652 | """ 653 | Handle a DELETE request. 654 | 655 | thing_id -- ID of the thing this request is for 656 | action_name -- name of the action from the URL path 657 | action_id -- the action ID from the URL path 658 | """ 659 | thing = self.get_thing(thing_id) 660 | if thing is None: 661 | self.set_status(404) 662 | return 663 | 664 | if thing.remove_action(action_name, action_id): 665 | self.set_status(204) 666 | else: 667 | self.set_status(404) 668 | 669 | 670 | class EventsHandler(BaseHandler): 671 | """Handle a request to /events.""" 672 | 673 | def get(self, thing_id='0'): 674 | """ 675 | Handle a GET request. 676 | 677 | thing_id -- ID of the thing this request is for 678 | """ 679 | thing = self.get_thing(thing_id) 680 | if thing is None: 681 | self.set_status(404) 682 | return 683 | 684 | self.set_header('Content-Type', 'application/json') 685 | self.write(json.dumps(thing.get_event_descriptions())) 686 | 687 | 688 | class EventHandler(BaseHandler): 689 | """Handle a request to /events/.""" 690 | 691 | def get(self, thing_id='0', event_name=None): 692 | """ 693 | Handle a GET request. 694 | 695 | thing_id -- ID of the thing this request is for 696 | event_name -- name of the event from the URL path 697 | """ 698 | thing = self.get_thing(thing_id) 699 | if thing is None: 700 | self.set_status(404) 701 | return 702 | 703 | self.set_header('Content-Type', 'application/json') 704 | self.write(json.dumps(thing.get_event_descriptions( 705 | event_name=event_name))) 706 | 707 | 708 | class WebThingServer: 709 | """Server to represent a Web Thing over HTTP.""" 710 | 711 | def __init__(self, things, port=80, hostname=None, ssl_options=None, 712 | additional_routes=None, base_path='', 713 | disable_host_validation=False): 714 | """ 715 | Initialize the WebThingServer. 716 | 717 | For documentation on the additional route format, see: 718 | https://www.tornadoweb.org/en/stable/web.html#tornado.web.Application 719 | 720 | things -- things managed by this server -- should be of type 721 | SingleThing or MultipleThings 722 | port -- port to listen on (defaults to 80) 723 | hostname -- Optional host name, i.e. mything.com 724 | ssl_options -- dict of SSL options to pass to the tornado server 725 | additional_routes -- list of additional routes to add to the server 726 | base_path -- base URL path to use, rather than '/' 727 | disable_host_validation -- whether or not to disable host validation -- 728 | note that this can lead to DNS rebinding 729 | attacks 730 | """ 731 | self.things = things 732 | self.name = things.get_name() 733 | self.port = port 734 | self.hostname = hostname 735 | self.base_path = base_path.rstrip('/') 736 | self.disable_host_validation = disable_host_validation 737 | 738 | system_hostname = socket.gethostname().lower() 739 | self.hosts = [ 740 | 'localhost', 741 | 'localhost:{}'.format(self.port), 742 | '{}.local'.format(system_hostname), 743 | '{}.local:{}'.format(system_hostname, self.port), 744 | ] 745 | 746 | for address in get_addresses(): 747 | self.hosts.extend([ 748 | address, 749 | '{}:{}'.format(address, self.port), 750 | ]) 751 | 752 | if self.hostname is not None: 753 | self.hostname = self.hostname.lower() 754 | self.hosts.extend([ 755 | self.hostname, 756 | '{}:{}'.format(self.hostname, self.port), 757 | ]) 758 | 759 | if isinstance(self.things, MultipleThings): 760 | for idx, thing in enumerate(self.things.get_things()): 761 | thing.set_href_prefix('{}/{}'.format(self.base_path, idx)) 762 | 763 | handlers = [ 764 | [ 765 | r'/?', 766 | ThingsHandler, 767 | dict( 768 | things=self.things, 769 | hosts=self.hosts, 770 | disable_host_validation=self.disable_host_validation, 771 | ), 772 | ], 773 | [ 774 | r'/(?P\d+)/?', 775 | ThingHandler, 776 | dict( 777 | things=self.things, 778 | hosts=self.hosts, 779 | disable_host_validation=self.disable_host_validation, 780 | ), 781 | ], 782 | [ 783 | r'/(?P\d+)/properties/?', 784 | PropertiesHandler, 785 | dict( 786 | things=self.things, 787 | hosts=self.hosts, 788 | disable_host_validation=self.disable_host_validation, 789 | ), 790 | ], 791 | [ 792 | r'/(?P\d+)/properties/' + 793 | r'(?P[^/]+)/?', 794 | PropertyHandler, 795 | dict( 796 | things=self.things, 797 | hosts=self.hosts, 798 | disable_host_validation=self.disable_host_validation, 799 | ), 800 | ], 801 | [ 802 | r'/(?P\d+)/actions/?', 803 | ActionsHandler, 804 | dict( 805 | things=self.things, 806 | hosts=self.hosts, 807 | disable_host_validation=self.disable_host_validation, 808 | ), 809 | ], 810 | [ 811 | r'/(?P\d+)/actions/(?P[^/]+)/?', 812 | ActionHandler, 813 | dict( 814 | things=self.things, 815 | hosts=self.hosts, 816 | disable_host_validation=self.disable_host_validation, 817 | ), 818 | ], 819 | [ 820 | r'/(?P\d+)/actions/' + 821 | r'(?P[^/]+)/(?P[^/]+)/?', 822 | ActionIDHandler, 823 | dict( 824 | things=self.things, 825 | hosts=self.hosts, 826 | disable_host_validation=self.disable_host_validation, 827 | ), 828 | ], 829 | [ 830 | r'/(?P\d+)/events/?', 831 | EventsHandler, 832 | dict( 833 | things=self.things, 834 | hosts=self.hosts, 835 | disable_host_validation=self.disable_host_validation, 836 | ), 837 | ], 838 | [ 839 | r'/(?P\d+)/events/(?P[^/]+)/?', 840 | EventHandler, 841 | dict( 842 | things=self.things, 843 | hosts=self.hosts, 844 | disable_host_validation=self.disable_host_validation, 845 | ), 846 | ], 847 | ] 848 | else: 849 | self.things.get_thing().set_href_prefix(self.base_path) 850 | handlers = [ 851 | [ 852 | r'/?', 853 | ThingHandler, 854 | dict( 855 | things=self.things, 856 | hosts=self.hosts, 857 | disable_host_validation=self.disable_host_validation, 858 | ), 859 | ], 860 | [ 861 | r'/properties/?', 862 | PropertiesHandler, 863 | dict( 864 | things=self.things, 865 | hosts=self.hosts, 866 | disable_host_validation=self.disable_host_validation, 867 | ), 868 | ], 869 | [ 870 | r'/properties/(?P[^/]+)/?', 871 | PropertyHandler, 872 | dict( 873 | things=self.things, 874 | hosts=self.hosts, 875 | disable_host_validation=self.disable_host_validation, 876 | ), 877 | ], 878 | [ 879 | r'/actions/?', 880 | ActionsHandler, 881 | dict( 882 | things=self.things, 883 | hosts=self.hosts, 884 | disable_host_validation=self.disable_host_validation, 885 | ), 886 | ], 887 | [ 888 | r'/actions/(?P[^/]+)/?', 889 | ActionHandler, 890 | dict( 891 | things=self.things, 892 | hosts=self.hosts, 893 | disable_host_validation=self.disable_host_validation, 894 | ), 895 | ], 896 | [ 897 | r'/actions/(?P[^/]+)/(?P[^/]+)/?', 898 | ActionIDHandler, 899 | dict( 900 | things=self.things, 901 | hosts=self.hosts, 902 | disable_host_validation=self.disable_host_validation, 903 | ), 904 | ], 905 | [ 906 | r'/events/?', 907 | EventsHandler, 908 | dict( 909 | things=self.things, 910 | hosts=self.hosts, 911 | disable_host_validation=self.disable_host_validation, 912 | ), 913 | ], 914 | [ 915 | r'/events/(?P[^/]+)/?', 916 | EventHandler, 917 | dict( 918 | things=self.things, 919 | hosts=self.hosts, 920 | disable_host_validation=self.disable_host_validation, 921 | ), 922 | ], 923 | ] 924 | 925 | if isinstance(additional_routes, list): 926 | handlers = additional_routes + handlers 927 | 928 | if self.base_path: 929 | for h in handlers: 930 | h[0] = self.base_path + h[0] 931 | 932 | self.app = tornado.web.Application(handlers) 933 | self.app.is_tls = ssl_options is not None 934 | self.server = tornado.httpserver.HTTPServer(self.app, 935 | ssl_options=ssl_options) 936 | 937 | def start(self): 938 | """Start listening for incoming connections.""" 939 | args = [ 940 | '_webthing._tcp.local.', 941 | '{}._webthing._tcp.local.'.format(self.name), 942 | ] 943 | kwargs = { 944 | 'addresses': [socket.inet_aton(get_ip())], 945 | 'port': self.port, 946 | 'properties': { 947 | 'path': '/', 948 | }, 949 | 'server': '{}.local.'.format(socket.gethostname()), 950 | } 951 | 952 | if self.app.is_tls: 953 | kwargs['properties']['tls'] = '1' 954 | 955 | self.service_info = ServiceInfo(*args, **kwargs) 956 | self.zeroconf = Zeroconf() 957 | self.zeroconf.register_service(self.service_info) 958 | 959 | self.server.listen(self.port) 960 | tornado.ioloop.IOLoop.current().start() 961 | 962 | def stop(self): 963 | """Stop listening.""" 964 | self.zeroconf.unregister_service(self.service_info) 965 | self.zeroconf.close() 966 | self.server.stop() 967 | --------------------------------------------------------------------------------