├── 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 |
--------------------------------------------------------------------------------