diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..44ad97f --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,6 @@ +[bumpversion] +current_version = 0.16.0 +commit = True +tag = True + +[bumpversion:file:./wotpy/__version__.py] diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..6d53653 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,35 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/python-3/.devcontainer/base.Dockerfile + +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG VARIANT="3.10-bullseye" +FROM mcr.microsoft.com/vscode/devcontainers/python:${VARIANT} + +# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4be44d7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,46 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/python-3 +{ + "name": "Python 3", + "dockerComposeFile": "docker-compose.devcontainer.yml", + "service": "wot-py", + "workspaceFolder": "/workspace", + "shutdownAction": "stopCompose", + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "/bin/bash .devcontainer/post-create-command.sh", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} diff --git a/.devcontainer/docker-compose.devcontainer.yml b/.devcontainer/docker-compose.devcontainer.yml new file mode 100644 index 0000000..21c3fca --- /dev/null +++ b/.devcontainer/docker-compose.devcontainer.yml @@ -0,0 +1,44 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +version: "3.9" + +services: + mqtt-broker: + image: eclipse-mosquitto:1.6 + + wot-py: + build: + context: .. + dockerfile: ./.devcontainer/Dockerfile + args: + # Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 + # Append -bullseye or -buster to pin to an OS version. + # Use -bullseye variants on local on arm64/Apple Silicon. + - VARIANT=3.8 + + command: /bin/sh -c "while sleep 1000; do :; done" + volumes: + - ..:/workspace:cached + environment: + - WOTPY_TESTS_MQTT_BROKER_URL=mqtt://mqtt-broker:1883 + depends_on: + - mqtt-broker \ No newline at end of file diff --git a/.devcontainer/post-create-command.sh b/.devcontainer/post-create-command.sh new file mode 100644 index 0000000..031c8d7 --- /dev/null +++ b/.devcontainer/post-create-command.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +set -e +set -x + +pip3 install --upgrade pip +pip3 install -U -e .[tests] \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d1a222 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,309 @@ + # Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +.venv/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# Pytest +.pytest_cache + +# Sphinx +docs/_autosummary + +### Eclipse template + +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# Eclipse Core +.project + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# JDT-specific (Eclipse Java Development Tools) +.classpath + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json +### NetBeans template +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml + +# Sensitive or high-churn files: +.idea/dataSources/ +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Entire IDEA folder +.idea +### Vim template +# swap +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +# session +Session.vim +# temporary +.netrwhist +*~ +# auto-generated tag files +tags + +### Java template +*.class + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +### VSCode template + +.vscode diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f18c39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,309 @@ + # Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +.venv/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# Pytest +.pytest_cache + +# Sphinx +docs/_autosummary + +### Eclipse template + +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# Eclipse Core +.project + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# JDT-specific (Eclipse Java Development Tools) +.classpath + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +pyvenv.cfg +.venv +pip-selfcheck.json +### NetBeans template +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml + +# Sensitive or high-churn files: +.idea/dataSources/ +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Entire IDEA folder +.idea +### Vim template +# swap +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +# session +Session.vim +# temporary +.netrwhist +*~ +# auto-generated tag files +tags + +### Java template +*.class + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +### VSCode template + +.vscode + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5f5cbfb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 CTIC Centro Tecnologico + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 81cce74..2e9f8ed 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,98 @@ -# wotpy -A WoT runtime in Python for Thing and Consumer applications + + +# VO-WoT + +This repository an updated version fo [WoTPy](https://github.com/agmangas/wot-py). + +[![PyPI](https://img.shields.io/pypi/v/wotpy)](https://pypi.org/project/wotpy/) + +## Introduction + +This repository is a fork of the original [WoTPy](https://github.com/agmangas/wot-py) repository. + +WoTPy is an experimental implementation of a [W3C WoT Runtime](https://github.com/w3c/wot-architecture/blob/master/proposals/terminology.md#wot-runtime) and the [W3C WoT Scripting API](https://github.com/w3c/wot-architecture/blob/master/proposals/terminology.md#scripting-api) in Python. + +Inspired by the exploratory implementations located in the [thingweb GitHub page](https://github.com/thingweb). + +## Features +- Supports Python 3 with versions >= 3.7 +- Fully-implemented `WoT` interface. +- Multicast discovery based on mDNS. +- Asynchronous I/O programming model based on coroutines. +- Multiple client and server [Protocol Binding](https://github.com/w3c/wot-architecture/blob/master/proposals/terminology.md#protocol-binding) implementations. + +### Feature support matrix + +| Feature | Python 3 | Implementation based on | +| -----------------: | ------------------ | ----------------------------------------------------------------------- | +| HTTP binding | :heavy_check_mark: | [tornadoweb/tornado](https://github.com/tornadoweb/tornado) | +| WebSockets binding | :heavy_check_mark: | [tornadoweb/tornado](https://github.com/tornadoweb/tornado) | +| CoAP binding | :heavy_check_mark: | [chrysn/aiocoap](https://github.com/chrysn/aiocoap) | +| MQTT binding | :heavy_check_mark: | [Yakifo/amqtt](https://github.com/Yakifo/amqtt) | +| mDNS discovery | :heavy_check_mark: | [jstasiak/python-zeroconf](https://github.com/jstasiak/python-zeroconf) | + + +## Installation +> :warning: For the moment, this is not the current version, and it won't be until we have reached some stability in the development. +``` +pip install wotpy +``` + +### Development + +To install in development mode with all the test dependencies: + +``` +pip install -U -e .[tests] +``` + +Some WoTPy features (e.g. CoAP binding) are not available outside of Linux. If you have Docker available in your system, and want to easily run the tests in a Linux environment (whether you're on macOS or Windows) you can use the Docker-based test script: + +``` +$ WOTPY_TESTS_MQTT_BROKER_URL=mqtt://192.168.1.141 ./pytest-docker-all.sh +... ++ docker run --rm -it -v /var/folders/zd/02pk7r3954s_t03lktjmvbdc0000gn/T/wotpy-547bed6bacf34ddc95b41eceb46553dd:/app -e WOTPY_TESTS_MQTT_BROKER_URL=mqtt://192.168.1.141 python:3.9 /bin/bash -c 'cd /app && pip install -U .[tests] && pytest -v --disable-warnings' +... +Python 3.7 :: OK +Python 3.8 :: OK +Python 3.9 :: OK +Python 3.10 :: OK +``` +`WOTPY_TESTS_MQTT_BROKER_URL` defines the url of the MQTT broker. It will listen to port `1883` by default. If your broker is set up in a different way, you can provide the port in the url as well. + +You can also test only for a specific Python version with the `PYTHON_TAG` variable and the `pytest-docker.sh` script like this: + +``` +$ WOTPY_TESTS_MQTT_BROKER_URL=mqtt://192.168.1.141 PYTHON_TAG=3.8 ./pytest-docker.sh +``` +### Development in VSCode with devcontainers +We have also provided a convenient `devcontainer` configuration to better recreate your local development environment. VSCode should detect it if you have the [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed. + +## Docs +Move to the `docs` folder and run: + +``` +make html +``` + diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..2e36811 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,23 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = WoTPy +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +clean: + rm -fr _build && rm -fr _autosummary + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..f5ac97b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,11 @@ +cdAPI Reference +============= + +.. autosummary:: + :toctree: _autosummary + + wotpy.wot + wotpy.utils + wotpy.protocols + wotpy.codecs + wotpy.cli \ No newline at end of file diff --git a/docs/coap.rst b/docs/coap.rst new file mode 100644 index 0000000..efc8560 --- /dev/null +++ b/docs/coap.rst @@ -0,0 +1,159 @@ +CoAP +==== + +This section describes the mapping between the high-level actions that can be executed on a Thing and the +messages exchanged with the server when using the CoAP Protocol Binding. + +All messages are serialized in JSON format. + +Form elements +------------- + +Form elements produced by the CoAP binding vary depending on the type of interaction. All forms contain the following fields: + +============= =========== +Field Description +============= =========== +``op`` Interaction verb (e.g. ``readproperty``) associated with this form element. +``href`` The CoAP URL used to interface with the Thing server for this interaction verb. +``mediaType`` This field will always contain the MIME media type for JSON. +============= =========== + +Servers in the CoAP binding expose three distinct resources, one for each interaction type (i.e. property, action and event). +Things and interactions are uniquely identified using query arguments in the CoAP URL for the appropriate resource. + +Interaction Model mapping +------------------------- + +.. note:: The CoAP binding leverages `CoAP Observe `_ to implement server-side messaging when invoking actions or subscribing to properties/events. + +Read Property +^^^^^^^^^^^^^ + +Form:: + + { + "op": "readproperty", + "contentType": "application/json", + "href": "coap://:/property?thing=&name=" + } + +Request:: + + GET coap://:/property?thing=&name= + +Response:: + + CoAP 2.05 Content + + { + "value": + } + +Write Property +^^^^^^^^^^^^^^ + +Form:: + + { + "op": "writeproperty", + "contentType": "application/json", + "href": "coap://:/property?thing=&name=" + } + +Request:: + + PUT coap://:/property?thing=&name= + + { + "value": + } + +Response:: + + CoAP 2.04 Changed + + +Observe Property changes +^^^^^^^^^^^^^^^^^^^^^^^^ + +Form:: + + { + "op": "observeproperty", + "contentType": "application/json", + "href": "coap://:/property?thing=&name=" + } + +The interface of the *observe property* verb is equivalent to the *read property* verb with the exception that the client must register as an **observer** (as defined by the RFC) to start receiving server-side messages. + +Invoke Action +^^^^^^^^^^^^^ + +Form:: + + { + "op": "invokeaction", + "contentType": "application/json", + "href": "coap://:/action?thing=&name=" + } + +An invocation is started by sending a POST request:: + + POST coap://:/action?thing=&name= + + { + "input": + } + +The invocation id assigned by the server will be contained in the response:: + + CoAP 2.01 Created + + { + "id": + } + +The client may check the invocation status by **observing** the resource and passing the invocation id in the payload:: + + GET coap://:/action?thing=&name= + + { + "id": + } + +Invocation status messages sent by the server have the following format:: + + CoAP 2.05 Content + + { + "done": , + "id": , + "result" , + "error": + } + +Observe Event +^^^^^^^^^^^^^ + +Form:: + + { + "op": "subscribeevent", + "contentType": "application/json", + "href": "coap://:/event?thing=&name=" + } + +Subscriptions to the event are created by **observing** the resource:: + + GET coap://:/event?thing=&name= + +Each server response for an active subscription will contain the most recent event emission:: + + CoAP 2.05 Content + + { + "name": , + "data": , + "time": + } diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..d4ea945 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,209 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/stable/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.abspath('.'), '..', 'wotpy')) + +from wotpy.__version__ import __version__ + +# -- Project information ----------------------------------------------------- + +project = u'WoTPy' +copyright = u'2018, Fundacion CTIC' +author = u'Andres Garcia Mangas' + +# The short X.Y version +version = __version__ +# The full version, including alpha/beta/rc tags +release = __version__ + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', + 'sphinx.ext.autosummary', + 'sphinx.ext.githubpages' +] + +autodoc_default_options = { + 'members': True, + 'show-inheritance': True, + 'undoc-members': True +} + +# Boolean indicating whether to scan all found documents for +# autosummary directives, and to generate stub pages for each. + +autosummary_generate = True + +# Add any paths that contain templates here, relative to this directory. + +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] + +source_suffix = '.rst' + +# The master toctree document. + +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. + +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . + +exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. + +pygments_style = 'sphinx' + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# + +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". + +html_static_path = [] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'WoTPydoc' + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). + +latex_documents = [( + master_doc, + 'WoTPy.tex', + u'WoTPy Documentation', + u'Andres Garcia Mangas', + 'manual' +)] + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). + +man_pages = [( + master_doc, + 'wotpy', + u'WoTPy Documentation', + [author], + 1 +)] + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) + +texinfo_documents = [( + master_doc, + 'WoTPy', + u'WoTPy Documentation', + author, + 'WoTPy', + 'One line description of project.', + 'Miscellaneous' +)] diff --git a/docs/http.rst b/docs/http.rst new file mode 100644 index 0000000..5c4be8d --- /dev/null +++ b/docs/http.rst @@ -0,0 +1,161 @@ +HTTP +==== + +This section describes the mapping between the high-level actions that can be executed on a Thing and the +messages exchanged with the server when using the HTTP Protocol Binding. + +All messages are serialized in JSON format. + +Form elements +------------- + +Form elements produced by the HTTP binding vary depending on the type of interaction. All forms contain the following fields: + +============= =========== +Field Description +============= =========== +``op`` Interaction verb (e.g. ``readproperty``) associated with this form element. +``href`` The HTTP URL used to interface with the Thing server for this interaction verb. +``mediaType`` This field will always contain the MIME media type for JSON. +============= =========== + +Interaction Model mapping +------------------------- + +.. note:: The HTTP binding adopts the *long-polling* pattern to deal with server-side messages. In practice, this means that the server will keep the connection open on requests to the *action invocation*, *property subscription* and *event subscription* endpoints until a value is emitted or a timeout occurs. + +Read Property +^^^^^^^^^^^^^ + +Form:: + + { + "op": "readproperty", + "contentType": "application/json", + "href": "http://://property/" + } + +Request:: + + GET http://://property/ + +Response:: + + HTTP 200 + + { + "value": + } + +Write Property +^^^^^^^^^^^^^^ + +Form:: + + { + "op": "writeproperty", + "contentType": "application/json", + "href": "http://://property/" + } + +Request:: + + PUT http://://property/ + + { + "value": + } + +Response:: + + HTTP 200 + +Invoke Action +^^^^^^^^^^^^^ + +Form:: + + { + "op": "invokeaction", + "contentType": "application/json", + "href": "http://://action/" + } + +An action invocation can be started with a POST request:: + + POST http://://action/ + + { + "input": + } + +A unique UUID will be assigned to the ongoing invocation and returned in the response:: + + HTTP 200 + + { + "invocation": "/invocation/" + } + +The status of the invocation can be retrieved from that URL:: + + GET http://:/invocation/ + +The response will contain the final ``result`` or an ``error`` message:: + + HTTP 200 + + { + "done": , + "result" , + "error": + } + + +Observe Property changes +^^^^^^^^^^^^^^^^^^^^^^^^ + +Form:: + + { + "op": "observeproperty", + "contentType": "application/json", + "href": "http://://property//subscription" + } + +Subscriptions are automatically managed by the HTTP binding. A subscription is initialized on each request and cancelled after a value is emitted:: + + GET http://://property//subscription + +The response format is the same as the *read property* verb:: + + HTTP 200 + + { + "value": + } + +Observe Event +^^^^^^^^^^^^^ + +Form:: + + { + "op": "subscribeevent", + "contentType": "application/json", + "href": "http://://event//subscription" + } + +Request:: + + GET http://://event//subscription + +Response:: + + HTTP 200 + + { + "payload": + } + +Please note that subscriptions are also managed automatically, as occurs in the *observe property* case. \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..fe53017 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +WoTPy +===== + +Table of Contents +----------------- + +.. toctree:: + :maxdepth: 2 + + protocols + api + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..4fc92a3 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,57 @@ +@rem Copyright (c) 2018 CTIC Centro Tecnologico +@rem +@rem Permission is hereby granted, free of charge, to any person obtaining a copy of +@rem this software and associated documentation files (the "Software"), to deal in +@rem the Software without restriction, including without limitation the rights to +@rem use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +@rem the Software, and to permit persons to whom the Software is furnished to do so, +@rem subject to the following conditions: +@rem +@rem The above copyright notice and this permission notice shall be included in all +@rem copies or substantial portions of the Software. +@rem +@rem THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +@rem IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +@rem FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +@rem COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +@rem IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +@rem CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +@rem +@rem SPDX-License-Identifier: MIT + +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=WoTPy + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/mqtt.rst b/docs/mqtt.rst new file mode 100644 index 0000000..4dfe0a4 --- /dev/null +++ b/docs/mqtt.rst @@ -0,0 +1,127 @@ +MQTT +==== + +This section describes the mapping between the high-level actions that can be executed on a Thing and the +messages exchanged with the MQTT broker when using the MQTT Protocol Binding. + +.. note:: Unlike the other bindings, the MQTT binding is not self-contained and requires the presence of an external MQTT broker. + +All messages are serialized in JSON format. + +Form elements +------------- + +Form elements produced by the MQTT binding vary depending on the type of interaction. All forms contain the following fields: + +============= =========== +Field Description +============= =========== +``op`` Interaction verb (e.g. ``readproperty``) associated with this form element. +``href`` Contains the MQTT broker URL joined with the servient ID and the name of an MQTT topic. +``mediaType`` This field will always contain the MIME media type for JSON. +============= =========== + +An example of an MQTT form ``href``:: + + mqtt://my.mqtt.broker:1883/my-servient/property/requests/benchmark-thing/currenttime + +* ``my.mqtt.broker:1883`` is the broker URL. +* ``my-servient`` is the servient ID used as a namespace to avoid collisions between servients using the same broker. +* ``property/requests/benchmark-thing/currenttime`` is the topic where messages are exchanged for this specific interaction and verb. + +Topics +------ + +There are six different types of topics used by clients and servers of the MQTT binding to exchange messages: + +================== =========== +Topic Pattern +================== =========== +Property request ``/property/requests//`` +Property update ``/property/updates//`` +Property write ACK ``/property/ack//`` +Action invocation ``/action/invocation//`` +Action result ``/action/result//`` +Event emission ``/event//`` +================== =========== + +The format of the messages published in these topics and the way the binding interfaces with them is described in the next section. + +Interaction Model mapping +------------------------- + +.. note:: There is no need to manually manage subscriptions as the MQTT server maintains an internal subscription to all properties and events throughout its lifetime. + +Read Property +^^^^^^^^^^^^^ + +The client may publish a message in the **property request** topic to force the server to publish the current value of the property:: + + { + "action": "read" + } + +The property value will be published in the **property update** topic:: + + { + "value": , + "timestamp": + } + +Observe Property changes +^^^^^^^^^^^^^^^^^^^^^^^^ + +All property changes are automatically published in the **property update** topic without further intervention from the client:: + + { + "value": , + "timestamp": + } + +Write Property +^^^^^^^^^^^^^^ + +To update the value of a property, the client will publish a message in the **property request** topic with the following format:: + + { + "action": "write", + "value": , + "ack": + } + +The server will acknowledge the write by publishing a message in the **property write ACK** topic:: + + { + "ack": + } + +Invoke Action +^^^^^^^^^^^^^ + +An invocation may be started by publishing a message in the **action invocation** topic:: + + { + "id": , + "input": + } + +The invocation result will be published in the **action result** topic:: + + { + "id": , + "timestamp": , + "result" , + "error": + } + +Observe Event +^^^^^^^^^^^^^ + +All event emissions are automatically published in the **event emission** topic without further intervention from the client:: + + { + "name": , + "data": , + "timestamp": + } + diff --git a/docs/protocols.rst b/docs/protocols.rst new file mode 100644 index 0000000..07ed508 --- /dev/null +++ b/docs/protocols.rst @@ -0,0 +1,11 @@ +.. _protocol-bindings: + +Protocol Bindings +================= + +.. toctree:: + + websockets + http + mqtt + coap \ No newline at end of file diff --git a/docs/websockets.rst b/docs/websockets.rst new file mode 100644 index 0000000..714cfbb --- /dev/null +++ b/docs/websockets.rst @@ -0,0 +1,300 @@ +WebSockets +========== + +This section describes the mapping between the high-level actions that can be executed on a Thing and the +messages exchanged with the server when using the WebSockets Protocol Binding. + +The format of the messages is based on `JSON-RPC 2.0 `_. + +Form elements +------------- + +Form elements associated with the WebSockets binding that are found in Thing Description documents serialized in +JSON-LD have the following format:: + + { + "href": "ws://host.fundacionctic.org:9393/temperaturething", + "mediaType": "application/json" + } + +============= =========== +Field Description +============= =========== +``href`` URL where the WebSocket server for this Interaction will respond. The WebSocket server URL is the same for all Interactions in a given Thing. +``mediaType`` This field will always contain the MIME media type for JSON. +============= =========== + +Messages format +--------------- + +All interactions with the WebSocket server are based on exchanging messages that contain serialized +JSON objects (JSON-RPC). + +**Request** messages are sent by the client to interact with one of the Thing Interactions:: + + { + "jsonrpc": "2.0", + "method": , + "params": , + "id": + } + +========== ======== =========== +Field Optional Description +========== ======== =========== +``method`` No ID of the method that is being requested (e.g. ``read_property``). +``params`` No Parameters for the method that is being requested. +``id`` Yes Message ID of the request. The response message associated with this request will contain the same ID. +========== ======== =========== + +**Response** messages are sent by the server to respond to client requests:: + + { + "jsonrpc": "2.0", + "result": , + "id": + } + +========== ======== =========== +Field Optional Description +========== ======== =========== +``result`` No Result for the request that originated this response. +``id`` No Message ID of the request. If the request didn't contain an ID this field will be *null*. +========== ======== =========== + +**Error** messages are sometimes returned instead of a response if some error arises:: + + { + "jsonrpc": "2.0", + "error": { + "code": , + "message": , + "data": + }, + "id": + } + +================= ======== =========== +Field Optional Description +================= ======== =========== +``error.code`` No Number code that identifies the error. +``error.message`` No Text description of the error. +``error.data`` Yes Arbitrary data associated with the error. +``id`` No Message ID of the request. If the request didn't contain an ID this field will be *null*. +================= ======== =========== + +**Emitted Item** messages are sent for active subscriptions when new events are emitted under that subscription:: + + { + "subscription": , + "name": , + "data": + } + +================ ======== =========== +Field Optional Description +================ ======== =========== +``subscription`` No ID of the subscription linked to this emitted item. +``name`` No Name of the event. +``data`` No Arbitrary event payload. +================ ======== =========== + +Interaction Model mapping +------------------------- + +Read Property +^^^^^^^^^^^^^ + +Request:: + + { + "jsonrpc": "2.0", + "method": "read_property", + "params": { + "name": + }, + "id": "09bca9be-7e78-4106-bf4e-e3d503290191" + } + +Response:: + + { + "jsonrpc": "2.0", + "result": , + "id": "09bca9be-7e78-4106-bf4e-e3d503290191" + } + +Write Property +^^^^^^^^^^^^^^ + +Request:: + + { + "jsonrpc": "2.0", + "method": "write_property", + "params": { + "name": , + "value": + }, + "id": "77b06e1f-02dd-4f17-a551-f86045d07099" + } + +Response:: + + { + "jsonrpc": "2.0", + "result": null, + "id": "77b06e1f-02dd-4f17-a551-f86045d07099" + } + +The value of ``result`` will always contain ``null`` to indicate that the property update was successfully applied. + +Invoke Action +^^^^^^^^^^^^^ + +Request:: + + { + "jsonrpc": "2.0", + "method": "invoke_action", + "params": { + "name": , + "parameters": + }, + "id": "ec7455c4-f08a-4e8f-85c1-8b944ad9dc0e" + } + +Response:: + + { + "jsonrpc": "2.0", + "result": , + "id": "ec7455c4-f08a-4e8f-85c1-8b944ad9dc0e" + } + +Observe Property changes +^^^^^^^^^^^^^^^^^^^^^^^^ + +Request:: + + { + "jsonrpc": "2.0", + "method": "on_property_change", + "params": { + "name": + }, + "id": "dcd518b1-97d6-4b4b-9483-f26907734165" + } + +Response:: + + { + "jsonrpc": "2.0", + "result": , + "id": "dcd518b1-97d6-4b4b-9483-f26907734165" + } + +Message sent by the server for each property change:: + + { + "subscription": , + "name": "propertychange", + "data": { + "name": , + "value": + } + } + +The value of ``data`` contains a JSON object that is the dict representation of an instance of +:py:class:`.PropertyChangeEventInit`. + +Observe Event +^^^^^^^^^^^^^ + +Request:: + + { + "jsonrpc": "2.0", + "method": "on_event", + "params": { + "name": + }, + "id": "7fc84fa6-ef83-474c-8c91-06965bde3749" + } + +Response:: + + { + "jsonrpc": "2.0", + "result": , + "id": "7fc84fa6-ef83-474c-8c91-06965bde3749" + } + +Message sent by the server for each event emission:: + + { + "subscription": , + "name": , + "data": + } + +Observe TD changes +^^^^^^^^^^^^^^^^^^ + +Request:: + + { + "jsonrpc": "2.0", + "method": "on_td_change", + "params": {}, + "id": "d52636f1-0c45-4603-8b7b-326937891917" + } + +Response:: + + { + "jsonrpc": "2.0", + "result": , + "id": "d52636f1-0c45-4603-8b7b-326937891917" + } + +Message sent by the server for each Thing Description change:: + + { + "subscription": , + "name": "descriptionchange", + "data": { + "td_change_type": , + "method": , + "name": , + "data": , + "description": + } + } + +The value of ``data`` contains a JSON object that is the dict representation of an instance of +:py:class:`.ThingDescriptionChangeEventInit`. + +Dispose subscription +^^^^^^^^^^^^^^^^^^^^ + +Request:: + + { + "jsonrpc": "2.0", + "method": "dispose", + "params": { + "subscription": + }, + "id": "e02de075-7d5f-4466-b901-2ffd96437939" + } + +Response:: + + { + "jsonrpc": "2.0", + "result": , + "id": "e02de075-7d5f-4466-b901-2ffd96437939" + } + +The value of ``result`` may contain ``null`` if an active subscription for the given ID could not be found. diff --git a/examples/basic-security-test/test-client.py b/examples/basic-security-test/test-client.py new file mode 100644 index 0000000..457bee6 --- /dev/null +++ b/examples/basic-security-test/test-client.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import json +import asyncio +import logging + +from wotpy.wot.servient import Servient +from wotpy.wot.wot import WoT +from wotpy.protocols.http.client import HTTPClient +from wotpy.protocols.coap.client import CoAPClient + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + + +async def main(): + coap_client = CoAPClient() + #http_client = HTTPClient() + security_scheme_dict = { + "scheme": "basic" + } + credentials_dict = { + "username": "user", + "password": "pass" + } + #http_client.set_security(security_scheme_dict, credentials_dict) + coap_client.set_security(security_scheme_dict, credentials_dict) + wot = WoT(servient=Servient(clients=[coap_client])) + + LOGGER.info('Clients: {}'.format(wot.servient.clients)) + consumed_thing = await wot.consume_from_url('http://127.0.0.1:9090/test') + + LOGGER.info('Consumed Thing: {}'.format(consumed_thing)) + result = await consumed_thing.read_property('dummy') + print(result) + new_property = await consumed_thing.read_property('new_property') + print(new_property) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/basic-security-test/test.py b/examples/basic-security-test/test.py new file mode 100644 index 0000000..0f89527 --- /dev/null +++ b/examples/basic-security-test/test.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import json +import logging + +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.coap.server import CoAPServer +from wotpy.wot.servient import Servient + +CATALOGUE_PORT = 9090 +HTTP_PORT = 9494 +COAP_PORT = 5683 + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + +TD = { + 'title': 'Test', + 'id': 'urn:dev:wot:test:test', + 'description': '''A test example.''', + 'securityDefinitions': { + 'basic_sc':{ + 'scheme':'basic' + } + }, + 'security': 'basic_sc', + '@context': [ + 'https://www.w3.org/2022/wot/td/v1.1', + ], + 'properties': { + 'dummy': { + 'type': 'integer' + } + } +} + +async def main(): + # LOGGER.info('Creating HTTP server on: {}'.format(HTTP_PORT)) + # http_server = HTTPServer( + # port=HTTP_PORT, + # security_scheme=TD['securityDefinitions']['basic_sc']) + + LOGGER.info('Creating CoAP server on: {}'.format(COAP_PORT)) + coap_server = CoAPServer( + port=COAP_PORT, + security_scheme=TD['securityDefinitions']['basic_sc']) + + LOGGER.info('Creating servient with TD catalogue on: {}'.format(CATALOGUE_PORT)) + servient = Servient(catalogue_port=CATALOGUE_PORT) + #servient.add_server(http_server) + servient.add_server(coap_server) + + credentials_dict = { + TD['title']: { + "username": "user", + "password": "pass" + } + } + servient.add_credentials(credentials_dict) + + LOGGER.info('Starting servient') + wot = await servient.start() + + LOGGER.info('Exposing and configuring Thing') + + # Produce the Thing from Thing Description + exposed_thing = wot.produce(json.dumps(TD)) + + # Initialize the property value + await exposed_thing.properties['dummy'].write(42) + + exposed_thing.expose() + + new_prop_name = 'new_property' + new_prop_dict = { + 'type': 'string', + 'observable': True + } + exposed_thing.add_property(new_prop_name, new_prop_dict, value='initial string value') + servient.refresh_forms() + + LOGGER.info('URL of Thing is at: {}'.format(exposed_thing.thing.url_name)) + + LOGGER.info(f'{TD["title"]} is ready') + + +if __name__ == '__main__': + LOGGER.info('Starting loop') + loop = asyncio.new_event_loop() + loop.create_task(main()) + loop.run_forever() diff --git a/examples/bearer-security-test/test-client.py b/examples/bearer-security-test/test-client.py new file mode 100644 index 0000000..bea6f18 --- /dev/null +++ b/examples/bearer-security-test/test-client.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import json +import asyncio +import logging + +from wotpy.wot.servient import Servient +from wotpy.wot.wot import WoT +from wotpy.protocols.http.client import HTTPClient + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + + +async def main(): + http_client = HTTPClient() + security_scheme_dict = { + "scheme": "bearer" + } + credentials_dict = { + "token": "jg0ksz4nug1yf0ayi8ohf" + } + http_client.set_security(security_scheme_dict, credentials_dict) + wot = WoT(servient=Servient(clients=[http_client])) + + LOGGER.info('Clients: {}'.format(wot.servient.clients)) + consumed_thing = await wot.consume_from_url('http://127.0.0.1:9090/test') + + LOGGER.info('Consumed Thing: {}'.format(consumed_thing)) + result = await consumed_thing.read_property('dummy') + print(result) + new_property = await consumed_thing.read_property('new_property') + print(new_property) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/bearer-security-test/test.py b/examples/bearer-security-test/test.py new file mode 100644 index 0000000..4f164d3 --- /dev/null +++ b/examples/bearer-security-test/test.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import json +import logging + +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.coap.server import CoAPServer +from wotpy.wot.servient import Servient + +CATALOGUE_PORT = 9090 +HTTP_PORT = 9494 +COAP_PORT = 5683 + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + +TD = { + 'title': 'Test', + 'id': 'urn:dev:wot:test:test', + 'description': '''A test example.''', + 'securityDefinitions': { + 'bearer_sc':{ + 'scheme':'bearer' + } + }, + 'security': 'bearer_sc', + '@context': [ + 'https://www.w3.org/2022/wot/td/v1.1', + ], + 'properties': { + 'dummy': { + 'type': 'integer' + } + } +} + +async def main(): + LOGGER.info('Creating HTTP server on: {}'.format(HTTP_PORT)) + http_server = HTTPServer( + port=HTTP_PORT, + security_scheme=TD['securityDefinitions']['bearer_sc']) + + #LOGGER.info('Creating CoAP server on: {}'.format(COAP_PORT)) + #coap_server = CoAPServer(port=COAP_PORT) + + LOGGER.info('Creating servient with TD catalogue on: {}'.format(CATALOGUE_PORT)) + servient = Servient(catalogue_port=CATALOGUE_PORT) + servient.add_server(http_server) + #servient.add_server(coap_server) + + credentials_dict = { + TD['title']: { + "token": "jg0ksz4nug1yf0ayi8ohf" + } + } + servient.add_credentials(credentials_dict) + + LOGGER.info('Starting servient') + wot = await servient.start() + + LOGGER.info('Exposing and configuring Thing') + + # Produce the Thing from Thing Description + exposed_thing = wot.produce(json.dumps(TD)) + + # Initialize the property value + await exposed_thing.properties['dummy'].write(42) + + exposed_thing.expose() + + new_prop_name = 'new_property' + new_prop_dict = { + 'type': 'string', + 'observable': True + } + exposed_thing.add_property(new_prop_name, new_prop_dict, value='initial string value') + servient.refresh_forms() + + LOGGER.info('URL of Thing is at: {}'.format(exposed_thing.thing.url_name)) + + LOGGER.info(f'{TD["title"]} is ready') + + +if __name__ == '__main__': + LOGGER.info('Starting loop') + loop = asyncio.new_event_loop() + loop.create_task(main()) + loop.run_forever() diff --git a/examples/benchmark/client.py b/examples/benchmark/client.py new file mode 100644 index 0000000..7fa0ec3 --- /dev/null +++ b/examples/benchmark/client.py @@ -0,0 +1,881 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +WoT application that consumes the benchmark Thing +and analyzes the captured packet traces. +""" + +import argparse +import asyncio +import collections +import copy +import json +import logging +import netifaces +import os +import pprint +import tempfile +import time +import uuid +from subprocess import Popen, PIPE, TimeoutExpired +from urllib.parse import urlparse + +import numpy +import pyshark +from tornado.simple_httpclient import HTTPTimeoutError + +from wotpy.protocols.enums import Protocols +from wotpy.protocols.exceptions import ClientRequestTimeout +from wotpy.protocols.http.client import HTTPClient +from wotpy.protocols.ws.client import WebsocketClient +from wotpy.wot.servient import Servient +from wotpy.wot.wot import WoT + +try: + from . import utils +except ImportError: + # noinspection PyPackageRequirements,PyUnresolvedReferences + import utils + +utils.init_logging() +logger = logging.getLogger() + +EVENT_BURST_TIMEOUT_MAX_LAMBD = 1.0 +EVENT_BURST_TIMEOUT_FACTOR = 1.5 +EVENT_BURST_TIMEOUT_MIN = 90.0 +EVENT_BURST_MAX_FINAL_WAIT = 30.0 +EVENT_BURST_SLEEP_FINAL_WAIT = 5.0 + +TARGET_BURST_EVENT = "burstEvent" +TARGET_ROUND_TRIP = "measureRoundTrip" +TARGET_CURR_TIME = "currentTime" + +TARGETS = [ + TARGET_BURST_EVENT, + TARGET_ROUND_TRIP, + TARGET_CURR_TIME +] + + +class ConsumedThingCapture: + """Represents a set of packets that have been captured + from the network interactions with a remote ConsumedThing.""" + + START_STOP_WINDOW_SECS = 2.0 + PROCESS_LOOP_SLEEP = 0.1 + PROCESS_TIMEOUT = 0.01 + + def __init__(self, consumed_thing): + self._process = None + self._output_file = None + self._consumed_thing = consumed_thing + + def _forms_generator(self): + """Generator that yields every Form in the ConsumedThing.""" + + intrct_dicts = [ + self._consumed_thing.properties, + self._consumed_thing.actions, + self._consumed_thing.events + ] + + for intrct_dict in intrct_dicts: + for name in intrct_dict: + for form in intrct_dict[name].forms: + yield form + + def _get_capture_hosts(self): + """Returns the list of hosts that are listed in the ConsumedThing Forms.""" + + hosts = set() + + for form in self._forms_generator(): + hosts.add(urlparse(form.href).hostname) + + logger.info("Hosts extracted from {}: {}".format( + self._consumed_thing, + pprint.pformat(hosts))) + + return list(hosts) + + async def start(self, iface=None): + """Starts capturing packets for the ConsumedThing + hosts in the given network interface.""" + + assert not self._process + assert not self._output_file + + self._output_file = os.path.join( + tempfile.gettempdir(), + "{}.pcapng".format(uuid.uuid4().hex)) + + filter_host = " or ".join([ + "(host {})".format(item) + for item in self._get_capture_hosts() + ]) + + command = [ + "tshark" + ] + + if iface is None: + inet_ifaces = [n for n in netifaces.interfaces() if netifaces.AF_INET in netifaces.ifaddresses(n)] + logger.info("Found AF_INET interfaces: {}".format(inet_ifaces)) + [command.extend(["-i", name]) for name in inet_ifaces] + else: + command.extend(["-i", iface]) + + command.extend([ + "-F", + "pcapng", + "-w", + self._output_file, + "-f", + filter_host + ]) + + logger.info("Running capture process: {}".format(command)) + + self._process = Popen(command, stdout=PIPE, stderr=PIPE, shell=False) + + await asyncio.sleep(self.START_STOP_WINDOW_SECS) + + async def stop(self): + """Stops the capture process that is currently in progress.""" + + assert self._process + assert self._output_file + + await asyncio.sleep(self.START_STOP_WINDOW_SECS) + + logger.info("Terminating process: {}".format(self._process)) + + self._process.terminate() + + while True: + try: + stdout, stderr = self._process.communicate(timeout=self.PROCESS_TIMEOUT) + break + except TimeoutExpired: + pass + + await asyncio.sleep(self.PROCESS_LOOP_SLEEP) + + logger.info("Terminated:\n\tstdout:: {}\n\tstderr:: {}".format(stdout, stderr)) + + self._process = None + + def _remove_output_file(self): + """Removes the temp pcapng file that contains the captured packets.""" + + if not self._output_file: + return + + logger.info("Removing temp capture file: {}".format(self._output_file)) + + os.remove(self._output_file) + + def clear(self): + """Cleans the internal state to enable this + instance to capture another set of packets.""" + + self._remove_output_file() + self._process = None + self._output_file = None + + def _build_display_filter(self, protocol, protocol_layer_only=False): + """Returns the Wireshark display filter for packets of the given protocol.""" + + default_ports = { + Protocols.HTTP: 80, + Protocols.WEBSOCKETS: 81, + Protocols.MQTT: 1883, + Protocols.COAP: 5683 + } + + assert protocol in default_ports.keys() + + transports = { + Protocols.HTTP: "tcp", + Protocols.WEBSOCKETS: "tcp", + Protocols.MQTT: "tcp", + Protocols.COAP: "udp" + } + + schemes = { + Protocols.HTTP: ["http", "https"], + Protocols.WEBSOCKETS: ["ws", "wss"], + Protocols.MQTT: ["mqtt", "mqtts"], + Protocols.COAP: ["coap", "coaps"] + } + + protocol_keys = { + Protocols.HTTP: "http", + Protocols.WEBSOCKETS: "websocket", + Protocols.MQTT: "mqtt" + } + + protocol_forms = [ + form for form in self._forms_generator() + if urlparse(form.href).scheme in schemes[protocol] + ] + + ports = { + urlparse(form.href).port if urlparse(form.href).port else default_ports[protocol] + for form in protocol_forms + } + + display_filter = " or ".join([ + "{}.port == {}".format(transports[protocol], port) + for port in ports + ]) + + if protocol in protocol_keys and protocol_layer_only: + display_filter = "({}) and {}".format(display_filter, protocol_keys[protocol]) + + return display_filter + + def filter_packets(self, protocol): + """Returns the set of captured packets that match the given protocol.""" + + assert not self._process + assert self._output_file + + display_filter = self._build_display_filter(protocol) + + logger.info("Building FileCapture for display filter ({}): {}".format(protocol, display_filter)) + + return pyshark.FileCapture(self._output_file, display_filter=display_filter) + + def get_capture_size(self, protocol): + """Returns the total size (bytes) of the captured packets for the given protocol.""" + + pyshark_cap = self.filter_packets(protocol) + size = sum([int(pkt.length) for pkt in pyshark_cap]) + pyshark_cap.close() + + return size + + +def time_millis(): + """Returns the current timestamp as an integer with ms precision.""" + + return int(time.time() * 1000) + + +def build_protocol_client(protocol): + """Factory function to build the protocol client for the given protocol.""" + + if protocol == Protocols.HTTP: + return HTTPClient() + elif protocol == Protocols.WEBSOCKETS: + return WebsocketClient() + elif protocol == Protocols.COAP: + from wotpy.protocols.coap.client import CoAPClient + return CoAPClient() + elif protocol == Protocols.MQTT: + from wotpy.protocols.mqtt.client import MQTTClient + return MQTTClient() + + +async def fetch_consumed_thing(td_url, protocol): + """Gets the remote Thing Description and returns a ConsumedThing.""" + + clients = [build_protocol_client(protocol)] + wot = WoT(servient=Servient(clients=clients)) + consumed_thing = await wot.consume_from_url(td_url) + + return consumed_thing + + +def count_disordered(arr, size): + """Counts the number of items that are out of the expected + order (monotonous increase) in the given list.""" + + counter = 0 + + state = { + "expected": next(item for item in range(size) if item in arr), + "checked": [] + } + + def advance_state(): + state["expected"] += 1 + + while True: + in_arr = state["expected"] in arr + is_overflow = state["expected"] > size + not_checked = state["expected"] not in state["checked"] + + if not_checked and (in_arr or is_overflow): + return + + state["expected"] += 1 + + for val in arr: + if val == state["expected"]: + advance_state() + else: + counter += 1 + + state["checked"].append(val) + + return counter + + +def get_arr_stats(arr): + """Takes a list of numbers and returns a dict with some statistics.""" + + return { + "mean": numpy.mean(arr).item() if len(arr) else None, + "median": numpy.median(arr).item() if len(arr) else None, + "std": numpy.std(arr).item() if len(arr) else None, + "var": numpy.var(arr).item() if len(arr) else None, + "max": numpy.max(arr).item() if len(arr) else None, + "min": numpy.min(arr).item() if len(arr) else None, + "p95": numpy.percentile(arr, 95).item() if len(arr) else None, + "p99": numpy.percentile(arr, 99).item() if len(arr) else None + } + + +def consume_event_burst(consumed_thing, protocol, iface=None, + sub_sleep=1.0, lambd=5.0, total=10, timeout_last_events=10): + """Gets the stats from invoking the action to initiate + an event burst and subscribing to those events.""" + + stats = {} + + loop = asyncio.get_event_loop() + + cap, events = loop.run_until_complete(_consume_event_burst( + consumed_thing, + iface, + sub_sleep, + lambd, + total, + timeout_last_events)) + + indexes = [item["index"] for item in events] + latencies = [item["timeReceived"] - item["timeEmission"] for item in events] + + time_received_sorted = sorted([float(item["timeReceived"]) for item in events]) + real_rate = len(events) / (0.001 * float(time_received_sorted[-1] - time_received_sorted[0])) + + num_unique_indexes = len(set(indexes)) + + stats.update({ + "protocol": protocol, + "lambd": lambd, + "total": total, + "size": cap.get_capture_size(protocol), + "disordered": count_disordered(indexes, total), + "loss": 1.0 - (float(num_unique_indexes) / total), + "latency": get_arr_stats(latencies), + "seriesLatency": latencies, + "realRate": round(real_rate, 3) + }) + + cap.clear() + + return stats + + +async def _consume_event_burst(consumed_thing, iface, sub_sleep, lambd, total, timeout_last_events): + """Coroutine helper for the consume_event_burst function.""" + + burst_id = uuid.uuid4().hex + done = asyncio.Future() + events = collections.deque([]) + + subscription = {"current": None} + + def on_next(item): + """Updates the list of items for the event burst and + resolves the completion Future when finished.""" + + if item.data.get("id") != burst_id: + return + + data = copy.deepcopy(item.data) + data.update({"timeReceived": time_millis()}) + events.append(data) + + logger.info("{}".format(item)) + + if item.data.get("burstEnd", False) and not done.done(): + done.set_result(True) + + def on_error(err): + """Tries to recreate the subscription.""" + + logger.warning("Subscription error :: {}".format(err)) + subscription["current"] = None + create_sub() + + def create_sub(): + """Initializes the subscription to the burst event.""" + + assert subscription["current"] is None + + logger.info("Initializing subscription") + + subscription["current"] = consumed_thing.events["burstEvent"].subscribe( + on_next=on_next, + on_completed=lambda: logger.info("Completed"), + on_error=on_error) + + cap = ConsumedThingCapture(consumed_thing) + + await cap.start(iface=iface) + + logger.info("Subscribing to burst event") + + create_sub() + + await asyncio.sleep(sub_sleep) + + logger.info("Invoking action to start event burst") + + lambd_timeout = lambd if lambd < EVENT_BURST_TIMEOUT_MAX_LAMBD else EVENT_BURST_TIMEOUT_MAX_LAMBD + timeout = (total / float(lambd_timeout)) * EVENT_BURST_TIMEOUT_FACTOR + timeout = timeout if timeout > EVENT_BURST_TIMEOUT_MIN else EVENT_BURST_TIMEOUT_MIN + + logger.info("Expected burst action timeout: {} s".format(timeout)) + + try: + await consumed_thing.actions["startEventBurst"].invoke({ + "id": burst_id, + "lambd": lambd, + "total": total + }, timeout=timeout) + except ClientRequestTimeout: + logger.warning("Timeout waiting for burst action") + + logger.info("Event burst action completed") + + def last_event_time_diff(): + """Returns the time difference (seconds) to the last received event.""" + + if not len(events): + return float("inf") + + return (time_millis() - events[-1]["timeReceived"]) * 0.001 + + logger.info("Waiting until events stop arriving") + + while last_event_time_diff() < EVENT_BURST_MAX_FINAL_WAIT: + logger.info("Waiting for events...") + await asyncio.sleep(EVENT_BURST_SLEEP_FINAL_WAIT) + + try: + logger.info("Entering last events grace period") + await asyncio.wait_for(done, timeout=timeout_last_events) + except asyncio.TimeoutError: + logger.warning("Timeout waiting for events") + + logger.info("Subscription disposal") + + subscription["current"].dispose() + + await cap.stop() + + return cap, events + + +def consume_round_trip_action(consumed_thing, protocol, iface=None, + num_batches=10, num_parallel=3, timeout_secs=90): + """Gets the stats from invoking the action to measure the round trip time.""" + + stats = {} + + loop = asyncio.get_event_loop() + + cap, results = loop.run_until_complete(_consume_round_trip_action( + consumed_thing, + iface, + num_batches, + num_parallel, + timeout_secs)) + + results_ok = [item for item in results if item["success"]] + + latencies = [ + (item["result"]["timeResponse"] - item["result"]["timeRequest"]) - + (item["result"]["timeReturn"] - item["result"]["timeArrival"]) + for item in results_ok + ] + + unsync_count = len([val for val in latencies if val < 0]) + + if unsync_count: + logger.warning("Unsynchronized latencies: {}".format(unsync_count)) + + stats.update({ + "protocol": protocol, + "numBatches": num_batches, + "numParallel": num_parallel, + "size": cap.get_capture_size(protocol), + "latency": get_arr_stats(latencies), + "unsyncLatency": unsync_count, + "successRatio": float(len(results_ok)) / len(results), + "seriesLatency": latencies + }) + + cap.clear() + + return stats + + +async def _consume_round_trip_action(consumed_thing, iface, num_batches, num_parallel, timeout_secs): + """Coroutine helper for the consume_round_trip_action function.""" + + results = [] + + cap = ConsumedThingCapture(consumed_thing) + + await cap.start(iface=iface) + + action = consumed_thing.actions["measureRoundTrip"] + + for idx in range(num_batches): + logger.info("Starting invocations batch {}/{}".format(idx + 1, num_batches)) + + invocations = [ + asyncio.ensure_future(action.invoke({"timeRequest": time_millis()}, timeout=timeout_secs)) + for _ in range(num_parallel) + ] + + counter = 0 + + for fut in asyncio.as_completed(invocations): + counter += 1 + + item = {} + + try: + result = await fut + result.update({"timeResponse": time_millis()}) + item.update({"success": True, "result": result}) + except Exception as ex: + logger.warning("Error on invocation: {}".format(ex), exc_info=True) + item = {"success": False, "error": ex} + + results.append(item) + + logger.info("Invocations progress {}/{} :: {}/{} ({})".format( + idx + 1, num_batches, counter, num_parallel, "OK" if item["success"] else "ERROR")) + + await cap.stop() + + return cap, results + + +def consume_time_prop(consumed_thing, protocol, iface=None, rate=20.0, total=100): + """Gets the stats from consuming the read-only time property.""" + + stats = {} + + loop = asyncio.get_event_loop() + + cap, results = loop.run_until_complete(_consume_time_prop( + consumed_thing, + iface, + rate, + total)) + + success_count = len([item for item in results if item["success"]]) + + latencies = [ + item["timeRes"] - item["timeReq"] + for item in results if item["success"] + ] + + stats.update({ + "protocol": protocol, + "rate": rate, + "total": len(results), + "size": cap.get_capture_size(protocol), + "successRatio": float(success_count) / len(results), + "latency": get_arr_stats(latencies), + "seriesLatency": latencies + }) + + cap.clear() + + return stats + + +async def _consume_time_prop(consumed_thing, iface, rate, total, start_delay=3.0, num_tasks=5): + """Coroutine helper for the consume_time_prop function.""" + + requests_queue = asyncio.Queue() + times_req = [] + times_res = [] + req_valid = [] + req_error = [] + + async def start_request_loop(times_queue): + """Gets a time from the given queue, sleeps until + that time arrives and sends a request in a loop.""" + + while True: + try: + time_next = times_queue.get_nowait() + time_curr = time.time() + + if time_curr < time_next: + await asyncio.sleep(time_next - time_curr) + + fut_res = asyncio.ensure_future(consumed_thing.properties["currentTime"].read()) + times_req.append((fut_res, time_millis())) + requests_queue.put_nowait(fut_res) + except asyncio.QueueEmpty: + logger.info("Requests producer Task finished") + break + + async def send_requests(): + """Sends the entire set of requests attempting to honor the given rate.""" + + interval_secs = 1.0 / rate + duration_expected = float(total) / rate + + logger.info("Sending {} total requests".format(total)) + logger.info("Rate: {}/s - Interval: {} s".format(rate, interval_secs)) + logger.info("Total expected duration: {} s".format(duration_expected)) + logger.info("Start delay: {} s".format(start_delay)) + + times_queue = asyncio.Queue() + time_start = time.time() + start_delay + + for idx in range(total): + times_queue.put_nowait(time_start + interval_secs * idx) + + await asyncio.wait([ + asyncio.ensure_future(start_request_loop(times_queue)) + for _ in range(num_tasks) + ]) + + logger.info("All requests sent") + + async def await_response(fut_res): + """Awaits the given Future response.""" + + try: + await fut_res + req_valid.append(fut_res) + except HTTPTimeoutError: + req_error.append(fut_res) + except Exception as ex: + logger.warning("Request error: {}".format(ex)) + req_error.append(fut_res) + finally: + times_res.append((fut_res, time_millis())) + + async def consume_requests_queue(): + """Consumes the requests queue to await on every Future response.""" + + total_consumed = 0 + awaited_responses = [] + + logger.info("Consuming requests queue") + + while total_consumed < total: + fut_res = await requests_queue.get() + awaited_res = asyncio.ensure_future(await_response(fut_res)) + awaited_responses.append(awaited_res) + total_consumed += 1 + + logger.info("Finished consuming requests queue") + + await asyncio.wait(awaited_responses) + + cap = ConsumedThingCapture(consumed_thing) + + await cap.start(iface=iface) + await asyncio.wait([send_requests(), consume_requests_queue()]) + + logger.info("Finished processing requests") + + await cap.stop() + + futs_times_req = [item[0] for item in times_req] + futs_times_res = [item[0] for item in times_res] + + assert set(req_valid + req_error) == set(futs_times_req) == set(futs_times_res) + + results = [] + + for res in req_valid + req_error: + is_success = res in req_valid + + results.append({ + "timeReq": next(item[1] for item in times_req if item[0] is res), + "timeRes": next(item[1] for item in times_res if item[0] is res), + "result": res.result() if is_success else None, + "success": is_success + }) + + return cap, results + + +def parse_args(): + """Parses and returns the command line arguments.""" + + parser = argparse.ArgumentParser(description="Benchmark Thing WoT client") + + parser.add_argument( + "--url", + dest="td_url", + required=True, + help="Benchmark Thing Description URL") + + parser.add_argument( + "--iface", + dest="capture_iface", + default=None, + help="Network interface to capture packages from") + + parser.add_argument( + "--protocol", + dest="protocol", + required=True, + choices=Protocols.list(), + help="Protocol binding that should be used by the WoT client") + + parser.add_argument( + "--output", + dest="output", + required=True, + help="Path to output JSON file") + + subparsers = parser.add_subparsers(dest="target") + subparsers.required = True + + parser_burst_event = subparsers.add_parser(TARGET_BURST_EVENT) + parser_burst_event.set_defaults(target=TARGET_BURST_EVENT) + parser_burst_event.add_argument("--lambd", dest="lambd", required=True, type=float) + parser_burst_event.add_argument("--total", dest="total", required=True, type=int) + + parser_round_trip = subparsers.add_parser(TARGET_ROUND_TRIP) + parser_round_trip.set_defaults(target=TARGET_ROUND_TRIP) + parser_round_trip.add_argument("--batches", dest="batches", required=True, type=int) + parser_round_trip.add_argument("--parallel", dest="parallel", required=True, type=int) + + parser_current_time = subparsers.add_parser(TARGET_CURR_TIME) + parser_current_time.set_defaults(target=TARGET_CURR_TIME) + parser_current_time.add_argument("--rate", dest="rate", required=True, type=float) + parser_current_time.add_argument("--total", dest="total", required=True, type=int) + + return parser.parse_args() + + +def main(): + """Main entrypoint.""" + + args = parse_args() + + logger.info("Arguments:\n{}".format(pprint.pformat(vars(args)))) + + loop = asyncio.get_event_loop() + + logger.info("Fetching TD from {}".format(args.td_url)) + + consumed_thing = loop.run_until_complete(fetch_consumed_thing( + args.td_url, + args.protocol)) + + logger.info("Consumed Thing: {}".format(consumed_thing)) + + def run_target_event_burst(): + logger.info("Consuming burst event (lambd={} total={})".format( + args.lambd, args.total)) + + return consume_event_burst( + consumed_thing, + args.protocol, + iface=args.capture_iface, + lambd=args.lambd, + total=args.total) + + def run_target_measure_round_trip(): + logger.info("Consuming round trip action (batches={} parallel={})".format( + args.batches, args.parallel)) + + return consume_round_trip_action( + consumed_thing, + args.protocol, + iface=args.capture_iface, + num_batches=args.batches, + num_parallel=args.parallel) + + def run_current_time(): + logger.info("Consuming time property (rate={} total={})".format( + args.rate, args.total)) + + return consume_time_prop( + consumed_thing, + args.protocol, + iface=args.capture_iface, + rate=args.rate, + total=args.total) + + func_map = { + TARGET_CURR_TIME: run_current_time, + TARGET_ROUND_TRIP: run_target_measure_round_trip, + TARGET_BURST_EVENT: run_target_event_burst + } + + stats = func_map[args.target]() + stats.update({"now": int(time.time() * 1000)}) + + logger.info("Stats (series have been mapped to length):\n{}".format(pprint.pformat({ + key: len(val) if key.startswith("series") else val + for key, val in stats.items() + }))) + + logger.info("Serializing results to: {}".format(args.output)) + + prev_raw = None + + try: + with open(args.output, "r") as fh: + prev_raw = fh.read() + except FileNotFoundError: + pass + + with open(args.output, "w") as fh: + content = json.loads(prev_raw) if prev_raw else {} + content[args.target] = content[args.target] if content.get(args.target) else {} + + content[args.target][args.protocol] = content[args.target][args.protocol] \ + if content[args.target].get(args.protocol) else [] + + content[args.target][args.protocol].append(stats) + fh.write(json.dumps(content)) + + +if __name__ == "__main__": + main() diff --git a/examples/benchmark/proxy.py b/examples/benchmark/proxy.py new file mode 100644 index 0000000..ac05c9f --- /dev/null +++ b/examples/benchmark/proxy.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +WoT application that consumes a Thing and exposes +another that serves as a proxy for the first. +""" + +import argparse +import asyncio +import json +import logging +import pprint + +from wotpy.wot.td import ThingDescription + +try: + from . import utils +except ImportError: + # noinspection PyPackageRequirements,PyUnresolvedReferences + import utils + +utils.init_logging() +logger = logging.getLogger() + +THING_ID = 'urn:org:fundacionctic:thing:proxy' +SUB_DELAY = 2.0 + +TIMEOUT_PROP_READ = 120.0 +TIMEOUT_PROP_WRITE = 120.0 +TIMEOUT_ACTION_INVOCATION = 1800.0 +TIMEOUT_HARD_FACTOR = 1.2 + + +def build_prop_read_proxy(consumed_thing, name): + """Factory for proxy Property read handlers.""" + + async def _proxy(): + timeout_soft = TIMEOUT_PROP_READ + timeout_hard = TIMEOUT_PROP_READ * TIMEOUT_HARD_FACTOR + + awaitable = consumed_thing.properties[name].read(timeout=timeout_soft) + + return await asyncio.wait_for(awaitable, timeout=timeout_hard) + + return _proxy + + +def build_prop_write_proxy(consumed_thing, name): + """Factory for proxy Property write handlers.""" + + async def _proxy(val): + timeout_soft = TIMEOUT_PROP_WRITE + timeout_hard = TIMEOUT_PROP_WRITE * TIMEOUT_HARD_FACTOR + + awaitable = consumed_thing.properties[name].write(val, timeout=timeout_soft) + + await asyncio.wait_for(awaitable, timeout=timeout_hard) + + return _proxy + + +def build_action_invoke_proxy(consumed_thing, name): + """Factory for proxy Action invocation handlers.""" + + async def _proxy(params): + timeout_soft = TIMEOUT_ACTION_INVOCATION + timeout_hard = TIMEOUT_ACTION_INVOCATION * TIMEOUT_HARD_FACTOR + + awaitable = consumed_thing.actions[name].invoke(params.get('input'), timeout=timeout_soft) + + return await asyncio.wait_for(awaitable, timeout=timeout_hard) + + return _proxy + + +def subscribe_event(consumed_thing, exposed_thing, name): + """Creates and maintains a subscription to the given Event, recreating it on error.""" + + state = {'sub': None} + + def _on_next(item): + logger.info("{}".format(item)) + exposed_thing.events[name].emit(item.data) + + def _on_completed(): + logger.info("Completed (Event {})".format(name)) + + def _on_error(err): + logger.warning("Error (Event {}) :: {}".format(name, err)) + + try: + logger.warning("Disposing of erroneous subscription") + state['sub'].dispose() + except Exception as ex: + logger.warning("Error disposing: {}".format(ex), exc_info=True) + + def _sub(): + logger.warning("Recreating subscription") + state['sub'] = consumed_thing.events[name].subscribe( + on_next=_on_next, + on_completed=_on_completed, + on_error=_on_error) + + logger.warning("Re-creating subscription in {} seconds".format(SUB_DELAY)) + + asyncio.get_event_loop().call_later(SUB_DELAY, _sub) + + state['sub'] = consumed_thing.events[name].subscribe( + on_next=_on_next, + on_completed=_on_completed, + on_error=_on_error) + + +async def expose_proxy(wot, consumed_thing): + """Takes a Consumed Thing and exposes an Exposed Thing that acts as a proxy.""" + + description = { + "id": THING_ID, + "name": "Thing Proxy: {}".format(consumed_thing.name) + } + + td_dict = consumed_thing.td.to_dict() + + for intrct_key in ['properties', 'actions', 'events']: + description.update({intrct_key: td_dict.get(intrct_key, {})}) + + exposed_thing = wot.produce(json.dumps(description)) + + for name in description.get('properties').keys(): + exposed_thing.set_property_read_handler(name, build_prop_read_proxy(consumed_thing, name)) + exposed_thing.set_property_write_handler(name, build_prop_write_proxy(consumed_thing, name)) + + for name in description.get('actions').keys(): + exposed_thing.set_action_handler(name, build_action_invoke_proxy(consumed_thing, name)) + + for name in description.get('events').keys(): + subscribe_event(consumed_thing, exposed_thing, name) + + exposed_thing.expose() + + logger.info("Exposed Thing proxy TD:\n{}".format( + pprint.pformat(ThingDescription.from_thing(exposed_thing.thing).to_dict()))) + + return exposed_thing + + +async def main(parsed_args): + """Main entrypoint.""" + + servient = utils.build_servient(parsed_args) + wot = await servient.start() + consumed_thing = await wot.consume_from_url(parsed_args.td_url) + logger.info("Building Exposed Thing proxy for Consumed Thing: {}".format(consumed_thing)) + await expose_proxy(wot, consumed_thing) + + +def parse_args(): + """Parses and returns the command line arguments.""" + + parser = argparse.ArgumentParser(description="WoT Proxy") + parser = utils.extend_server_arg_parser(parser) + + parser.add_argument( + "--url", + dest="td_url", + required=True, + help="Proxied Thing TD URL") + + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + loop = asyncio.get_event_loop() + loop.create_task(main(args)) + loop.run_forever() diff --git a/examples/benchmark/requirements.txt b/examples/benchmark/requirements.txt new file mode 100644 index 0000000..0e6d36f --- /dev/null +++ b/examples/benchmark/requirements.txt @@ -0,0 +1,27 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +Logbook==1.4.1 +lxml==4.9.1 +py==1.7.0 +pyshark==0.4.2.2 +numpy==1.22.0 +netifaces==0.10.9 \ No newline at end of file diff --git a/examples/benchmark/server.py b/examples/benchmark/server.py new file mode 100644 index 0000000..bcd4cf2 --- /dev/null +++ b/examples/benchmark/server.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +WoT application that exposes a Thing with interactions +to check the performance of the Servient. +""" + +import argparse +import asyncio +import json +import logging +import pprint +import random +import time +import uuid + +from wotpy.wot.enums import DataType + +try: + from . import utils +except ImportError: + # noinspection PyPackageRequirements,PyUnresolvedReferences + import utils + +DESCRIPTION = { + "id": "urn:org:fundacionctic:thing:benchmark", + "name": "Benchmark Thing", + "properties": { + "currentTime": { + "type": DataType.INTEGER, + "readOnly": True + } + }, + "actions": { + "measureRoundTrip": { + "safe": True, + "idempotent": False, + "input": { + "type": DataType.OBJECT + }, + "output": { + "type": DataType.OBJECT + } + }, + "startEventBurst": { + "safe": True, + "idempotent": False, + "input": { + "type": DataType.OBJECT + } + } + }, + "events": { + "burstEvent": { + "data": { + "type": DataType.OBJECT + } + } + } +} + +utils.init_logging() +logger = logging.getLogger() + +DEFAULT_RTRIP_MU = 0.0 +DEFAULT_RTRIP_SIGMA = 1.0 +DEFAULT_RTRIP_LOOP_SLEEP = 0.1 +DEFAULT_BURST_LAMBD = 5.0 +DEFAULT_BURST_TOTAL = 10 + + +def time_millis(): + """Returns the current timestamp as an integer with ms precision.""" + + return int(time.time() * 1000) + + +async def current_time_handler(): + """Custom handler for the currentTime property.""" + + return time_millis() + + +async def measure_round_trip(parameters): + """Handler for the action used to measure round trip time.""" + + time_arrival = time_millis() + + input_dict = parameters["input"] if parameters["input"] else {} + + mu = input_dict.get("mu", DEFAULT_RTRIP_MU) + sigma = input_dict.get("sigma", DEFAULT_RTRIP_SIGMA) + sleep_secs = abs(random.gauss(mu, sigma)) + sleep_end = time.time() + sleep_secs + + while time.time() < sleep_end: + await asyncio.sleep(DEFAULT_RTRIP_LOOP_SLEEP) + + time_return = time_millis() + + input_dict.update({ + "timeArrival": time_arrival, + "timeReturn": time_return + }) + + return input_dict + + +def build_event_burst_handler(exposed_thing): + """Factory function to build the handler for the action that initiates event bursts.""" + + async def start_event_burst(parameters): + """Emits a series of events where the total count and interval + between each emission is determined by the given parameters.""" + + time_start = time_millis() + + input_dict = parameters["input"] if parameters["input"] else {} + + lambd = input_dict.get("lambd", DEFAULT_BURST_LAMBD) + total = input_dict.get("total", DEFAULT_BURST_TOTAL) + burst_id = input_dict.get("id", uuid.uuid4().hex) + + for idx in range(total): + exposed_thing.emit_event("burstEvent", { + "id": burst_id, + "index": idx, + "timeStart": time_start, + "timeEmission": time_millis(), + "burstEnd": idx == total - 1 + }) + + await asyncio.sleep(random.expovariate(lambd)) + + return start_event_burst + + +async def main(parsed_args): + """Main entrypoint.""" + + servient = utils.build_servient(parsed_args) + + wot = await servient.start() + + logger.info("Exposing:\n{}".format(pprint.pformat(DESCRIPTION))) + + exposed_thing = wot.produce(json.dumps(DESCRIPTION)) + exposed_thing.set_action_handler("measureRoundTrip", measure_round_trip) + exposed_thing.set_action_handler("startEventBurst", build_event_burst_handler(exposed_thing)) + exposed_thing.set_property_read_handler("currentTime", current_time_handler) + exposed_thing.expose() + + +def parse_args(): + """Parses and returns the command line arguments.""" + + parser = argparse.ArgumentParser(description="Benchmark Thing WoT server") + parser = utils.extend_server_arg_parser(parser) + + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + loop = asyncio.get_event_loop() + loop.create_task(main(args)) + loop.run_forever() diff --git a/examples/benchmark/utils.py b/examples/benchmark/utils.py new file mode 100644 index 0000000..2c96b87 --- /dev/null +++ b/examples/benchmark/utils.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +""" + +import logging + +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.servient import Servient + +LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +LOGGER_NAME = 'wotpy' + + +def init_logging(): + """Initializes the logging subsystem.""" + + logging.basicConfig(format=LOG_FORMAT) + logger = logging.getLogger() + logger.setLevel(logging.INFO) + logging.getLogger('wotpy').setLevel(logging.DEBUG) + + +def build_servient(parsed_args, clients_config=None): + """Factory function to build a Servient with a set + of servers depending on the input arguments.""" + + logger = logging.getLogger() + + logger.info("Creating servient with TD catalogue on: {}".format(parsed_args.port_catalogue)) + + servient = Servient( + catalogue_port=parsed_args.port_catalogue, + hostname=parsed_args.hostname, + clients_config=clients_config) + + if parsed_args.port_ws > 0: + logger.info("Creating WebSocket server on: {}".format(parsed_args.port_ws)) + servient.add_server(WebsocketServer(port=parsed_args.port_ws)) + + if parsed_args.port_http > 0: + logger.info("Creating HTTP server on: {}".format(parsed_args.port_http)) + servient.add_server(HTTPServer(port=parsed_args.port_http)) + + if parsed_args.mqtt_broker: + try: + from wotpy.protocols.mqtt.server import MQTTServer + logger.info("Creating MQTT server on broker: {}".format(parsed_args.mqtt_broker)) + mqtt_server = MQTTServer(parsed_args.mqtt_broker, servient_id=servient.hostname) + servient.add_server(mqtt_server) + logger.info("MQTT server created with ID: {}".format(mqtt_server.servient_id)) + except NotImplementedError as ex: + logger.warning(ex) + + if parsed_args.port_coap > 0: + try: + from wotpy.protocols.coap.server import CoAPServer + logger.info("Creating CoAP server on: {}".format(parsed_args.port_coap)) + servient.add_server(CoAPServer(port=parsed_args.port_coap)) + except NotImplementedError as ex: + logger.warning(ex) + + return servient + + +def extend_server_arg_parser(parser): + """Adds server-related arguments to the given argument parser.""" + + parser.add_argument( + '--port-catalogue', + dest="port_catalogue", + default=9090, + type=int, + help="Thing Description catalogue port") + + parser.add_argument( + '--port-http', + dest="port_http", + default=9191, + type=int, + help="HTTP server port") + + parser.add_argument( + '--port-ws', + dest="port_ws", + default=9292, + type=int, + help="WebSockets server port") + + parser.add_argument( + '--port-coap', + dest="port_coap", + default=9393, + type=int, + help="CoAP server port") + + parser.add_argument( + '--mqtt-broker', + dest="mqtt_broker", + default="mqtt://localhost", + help="MQTT broker URL") + + parser.add_argument( + '--hostname', + dest="hostname", + default=None, + help="Servient hostname") + + return parser diff --git a/examples/cli/README.md b/examples/cli/README.md new file mode 100644 index 0000000..2d37b94 --- /dev/null +++ b/examples/cli/README.md @@ -0,0 +1,28 @@ + + +# Simple cli example +To run the example: +```py +python ../../wotpy/cli/cli.py coffee-machine-reduced.py -f config.json +``` \ No newline at end of file diff --git a/examples/cli/coffee-machine-reduced.py b/examples/cli/coffee-machine-reduced.py new file mode 100644 index 0000000..b54cdf8 --- /dev/null +++ b/examples/cli/coffee-machine-reduced.py @@ -0,0 +1,381 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import json +import logging +import math + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + +TD = { + 'title': 'Smart-Coffee-Machine', + 'id': 'urn:dev:wot:example:coffee-machine', + 'description': '''A smart coffee machine with a range of capabilities. +A complementary tutorial is available at http://www.thingweb.io/smart-coffee-machine.html.''', + 'support': 'git://github.com/eclipse/thingweb.node-wot.git', + '@context': [ + 'https://www.w3.org/2019/wot/td/v1', + ], + 'securityDefinitions': { + 'nosec_sc':{ + 'scheme':'nosec' + } + }, + 'security': 'nosec_sc', + 'properties': { + 'allAvailableResources': { + 'type': 'object', + 'description': '''Current level of all available resources given as an integer percentage for each particular resource. +The data is obtained from the machine's sensors but can be set manually in case the sensors are broken.''', + 'properties': { + 'water': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + 'milk': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + 'chocolate': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + 'coffeeBeans': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + }, + }, + 'possibleDrinks': { + 'type': 'array', + 'description': '''The list of possible drinks in general. Doesn't depend on the available resources.''', + 'items': { + 'type': 'string', + } + }, + 'servedCounter': { + 'type': 'integer', + 'description': '''The total number of served beverages.''', + 'minimum': 0, + }, + 'maintenanceNeeded': { + 'type': 'boolean', + 'description': '''Shows whether a maintenance is needed. The property is observable. Automatically set to True when the servedCounter property exceeds 1000.''', + 'observable': True, + }, + 'schedules': { + 'type': 'array', + 'description': '''The list of scheduled tasks.''', + 'items': { + 'type': 'object', + 'properties': { + 'drinkId': { + 'type': 'string', + 'description': '''Defines what drink to make, drinkId is one of possibleDrinks property values, e.g. latte.''', + }, + 'size': { + 'type': 'string', + 'description': '''Defines the size of a drink, s = small, m = medium, l = large.''', + 'enum': ['s', 'm', 'l'], + }, + 'quantity': { + 'type': 'integer', + 'description': '''Defines how many drinks to make, ranging from 1 to 5.''', + 'minimum': 1, + 'maximum': 5, + }, + 'time': { + 'type': 'string', + 'description': '''Defines the time of the scheduled task in 24h format, e.g. 10:00 or 21:00.''', + }, + 'mode': { + 'type': 'string', + 'description': '''Defines the mode of the scheduled task, e.g. once or everyday. All the possible values are given in the enum field of this Thing Description.''', + 'enum': ['once', 'everyday', 'everyMo', 'everyTu', 'everyWe', 'everyTh', 'everyFr', 'everySat', 'everySun'], + }, + }, + }, + }, + }, + 'actions': { + 'makeDrink': { + 'description': '''Make a drink from available list of beverages. Accepts drink id, size and quantity as input. +Brews one medium americano if no input is specified.''', + 'input': { + 'type': 'object', + 'properties': { + 'drinkId': { + 'type': 'string', + 'description': '''Defines what drink to make, drinkId is one of possibleDrinks property values, e.g. latte.''', + }, + 'size': { + 'type': 'string', + 'description': '''Defines the size of a drink, s = small, m = medium, l = large.''', + 'enum': ['s', 'm', 'l'], + }, + 'quantity': { + 'type': 'integer', + 'description': '''Defines how many drinks to make, ranging from 1 to 5.''', + 'minimum': 1, + 'maximum': 5 + }, + }, + }, + 'output': { + 'type': 'object', + 'description': '''Returns True/false and a message when all invoked promises are resolved (asynchronous).''', + 'properties': { + 'result': { + 'type': 'boolean', + }, + 'message': { + 'type': 'string', + }, + }, + }, + }, + 'setSchedule': { + 'description': '''Add a scheduled task to the schedules property. Accepts drink id, size, quantity, time and mode as body of a request. +Assumes one medium americano if not specified, but time and mode are mandatory fields.''', + 'input': { + 'type': 'object', + 'properties': { + 'drinkId': { + 'type': 'string', + 'description': '''Defines what drink to make, drinkId is one of possibleDrinks property values, e.g. latte.''', + }, + 'size': { + 'type': 'string', + 'description': '''Defines the size of a drink, s = small, m = medium, l = large.''', + 'enum': ['s', 'm', 'l'], + }, + 'quantity': { + 'type': 'integer', + 'description': '''Defines how many drinks to make, ranging from 1 to 5.''', + 'minimum': 1, + 'maximum': 5 + }, + 'time': { + 'type': 'string', + 'description': '''Defines the time of the scheduled task in 24h format, e.g. 10:00 or 21:00.''', + }, + 'mode': { + 'type': 'string', + 'description': '''Defines the mode of the scheduled task, e.g. once or everyday. All the possible values are given in the enum field of this Thing Description.''', + 'enum': ['once', 'everyday', 'everyMo', 'everyTu', 'everyWe', 'everyTh', 'everyFr', 'everySat', 'everySun'], + }, + }, + 'required': ['time', 'mode'], + }, + 'output': { + 'type': 'object', + 'description': '''Returns True/false and a message when all invoked promises are resolved (asynchronous).''', + 'properties': { + 'result': { + 'type': 'boolean', + }, + 'message': { + 'type': 'string', + }, + }, + }, + }, + }, + 'events': { + 'outOfResource': { + 'description': '''Out of resource event. Emitted when the available resource level is not sufficient for a desired drink.''', + 'data': { + 'type': 'string', + }, + }, + }, +} + +async def main(wot): + LOGGER.info('Exposing and configuring Thing') + + # Produce the Thing from Thing Description + exposed_thing = wot.produce(json.dumps(TD)) + + # Initialize the property values + await exposed_thing.properties['allAvailableResources'].write({ + 'water': read_from_sensor('water'), + 'milk': read_from_sensor('milk'), + 'chocolate': read_from_sensor('chocolate'), + 'coffeeBeans': read_from_sensor('coffeeBeans'), + }) + await exposed_thing.properties['possibleDrinks'].write(['espresso', 'americano', 'cappuccino', 'latte', 'hotChocolate', 'hotWater']) + await exposed_thing.properties['maintenanceNeeded'].write(False) + await exposed_thing.properties['schedules'].write([]) + + # # Observe the value of maintenanceNeeded property + exposed_thing.properties['maintenanceNeeded'].subscribe( + + # Notify a "maintainer" when the value has changed + # (the notify function here simply logs a message to the console) + + on_next=lambda data: notify(f'Value changed for an observable property: {data}'), + on_completed=notify('Subscribed for an observable property: maintenanceNeeded'), + on_error=lambda error: notify(f'Error for an observable property maintenanceNeeded: {error}') + ) + + # Override a write handler for servedCounter property, + # raising maintenanceNeeded flag when the value exceeds 1000 drinks + async def served_counter_write_handler(value): + + await exposed_thing._default_update_property_handler('servedCounter', value) + + if value > 1000: + await exposed_thing.properties['maintenanceNeeded'].write(True) + + exposed_thing.set_property_write_handler('servedCounter', served_counter_write_handler) + + # Now initialize the servedCounter property + await exposed_thing.properties['servedCounter'].write(read_from_sensor('servedCounter')) + + # Set up a handler for makeDrink action + async def make_drink_action_handler(params): + params = params['input'] if params['input'] else {} + + # Default values + drinkId = 'americano' + size = 'm' + quantity = 1 + + # Size quantifiers + sizeQuantifiers = {'s': 0.1, 'm': 0.2, 'l': 0.3} + + # Drink recipes showing the amount of a resource consumed for a particular drink + drinkRecipes = { + 'espresso': { + 'water': 1, + 'milk': 0, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'americano': { + 'water': 2, + 'milk': 0, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'cappuccino': { + 'water': 1, + 'milk': 1, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'latte': { + 'water': 1, + 'milk': 2, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'hotChocolate': { + 'water': 0, + 'milk': 0, + 'chocolate': 1, + 'coffeeBeans': 0, + }, + 'hotWater': { + 'water': 1, + 'milk': 0, + 'chocolate': 0, + 'coffeeBeans': 0, + }, + } + + # Check if params are provided + drinkId = params.get('drinkId', drinkId) + size = params.get('size', size) + quantity = params.get('quantity', quantity) + + # Read the current level of allAvailableResources + resources = await exposed_thing.read_property('allAvailableResources') + + # Calculate the new level of resources + newResources = resources.copy() + newResources['water'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['water']) + newResources['milk'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['milk']) + newResources['chocolate'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['chocolate']) + newResources['coffeeBeans'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['coffeeBeans']) + + # Check if the amount of available resources is sufficient to make a drink + for resource, value in newResources.items(): + if value <= 0: + # Emit outOfResource event + exposed_thing.emit_event('outOfResource', f'Low level of {resource}: {resources[resource]}%') + return {'result': False, 'message': f'{resource} level is not sufficient'} + + # Now store the new level of allAvailableResources and servedCounter + await exposed_thing.properties['allAvailableResources'].write(newResources) + + servedCounter = await exposed_thing.read_property('servedCounter') + servedCounter += quantity + await exposed_thing.properties['servedCounter'].write(servedCounter) + + # Finally deliver the drink + return {'result': True, 'message': f'Your {drinkId} is in progress!'} + + exposed_thing.set_action_handler('makeDrink', make_drink_action_handler) + + # Set up a handler for setSchedule action + async def set_schedule_action_handler(params): + params = params['input'] if params['input'] else {} + + # Check if required fields are provided in input + if 'time' in params and 'mode' in params: + + # Use default values for non-required fields if not provided + params['drinkId'] = params.get('drinkId', 'americano') + params['size'] = params.get('size', 'm') + params['quantity'] = params.get('quantity', 1) + + # Now read the schedules property, add a new schedule to it and then rewrite the schedules property + schedules = await exposed_thing.read_property('schedules') + schedules.append(params) + await exposed_thing.properties['schedules'].write(schedules) + return {'result': True, 'message': 'Your schedule has been set!'} + + return {'result': False, 'message': 'Please provide all the required parameters: time and mode.'} + + exposed_thing.set_action_handler('setSchedule', set_schedule_action_handler) + + exposed_thing.expose() + LOGGER.info(f'{TD["title"]} is ready') + + +def read_from_sensor(sensorType): + # Actual implementation of reading data from a sensor can go here + # For the sake of example, let's just return a value + return 100 + + +def notify(msg, subscribers=['admin@coffeeMachine.com']): + # Actual implementation of notifying subscribers with a message can go here + LOGGER.info(msg) + diff --git a/examples/cli/config.json b/examples/cli/config.json new file mode 100644 index 0000000..f5d5e8a --- /dev/null +++ b/examples/cli/config.json @@ -0,0 +1,26 @@ +{ + "servient": { + "clientOnly": false + }, + "http": { + "port": 8080, + "enabled": true, + "security": { + "scheme": "nosec" + } + }, + "coap": { + "port": 5683, + "enabled": true, + "security": { + "scheme": "nosec" + } + }, + "mqtt": { + "enabled": false + }, + "ws": { + "port": 8081, + "enabled": true + } +} \ No newline at end of file diff --git a/examples/coffee-machine/coffee-machine-client.py b/examples/coffee-machine/coffee-machine-client.py new file mode 100644 index 0000000..4d3e47b --- /dev/null +++ b/examples/coffee-machine/coffee-machine-client.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +''' +This is an example of Web of Things consumer ("client" mode) Thing script. +It considers a fictional smart coffee machine in order to demonstrate the capabilities of Web of Things. +The example is ported from the node-wot environment - +https://github.com/eclipse/thingweb.node-wot/blob/master/packages/examples/src/scripts/coffee-machine-client.ts. +''' +import json +import asyncio +import logging + +from wotpy.wot.servient import Servient +from wotpy.wot.wot import WoT + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + + +async def main(): + wot = WoT(servient=Servient()) + consumed_thing = await wot.consume_from_url('http://127.0.0.1:9090/smart-coffee-machine') + + LOGGER.info('Consumed Thing: {}'.format(consumed_thing)) + + # Read property allAvailableResources + allAvailableResources = await consumed_thing.read_property('allAvailableResources') + LOGGER.info('allAvailableResources value is: {}'.format(allAvailableResources)) + + # Now let's change water level to 80 + allAvailableResources['water'] = 80 + await consumed_thing.write_property('allAvailableResources', allAvailableResources) + + # And see that the water level has changed + allAvailableResources = await consumed_thing.read_property('allAvailableResources') + LOGGER.info('allAvailableResources value after change is: {}'.format(allAvailableResources)) + + # It's also possible to set a client-side handler for observable properties + consumed_thing.properties['maintenanceNeeded'].subscribe( + on_next=lambda data: LOGGER.info(f'Value changed for an observable property: {data}'), + on_completed=LOGGER.info('Subscribed for an observable property: maintenanceNeeded'), + on_error=lambda error: LOGGER.info(f'Error for an observable property maintenanceNeeded: {error}') + ) + + # Now let's make 3 cups of latte! + makeCoffee = await consumed_thing.invoke_action('makeDrink', {'drinkId': 'latte', 'size': 'l', 'quantity': 3}) + if makeCoffee.get('result'): + LOGGER.info('Enjoy your drink! \n{}'.format(makeCoffee)) + else: + LOGGER.info('Failed making your drink: {}'.format(makeCoffee)) + + # See how allAvailableResources property value has changed + allAvailableResources = await consumed_thing.read_property('allAvailableResources') + LOGGER.info('allAvailableResources value is: {}'.format(allAvailableResources)) + + # Let's add a scheduled task + scheduledTask = await consumed_thing.invoke_action('setSchedule', { + 'drinkId': 'espresso', + 'size': 'm', + 'quantity': 2, + 'time': '10:00', + 'mode': 'everyday' + }) + LOGGER.info(f'{scheduledTask["message"]} \n{scheduledTask}') + + # See how it has been added to the schedules property + schedules = await consumed_thing.read_property('schedules') + LOGGER.info('schedules value is: \n{}'.format(json.dumps(schedules, indent=2))) + + # Let's set up a handler for outOfResource event + consumed_thing.events['outOfResource'].subscribe( + on_next=lambda data: LOGGER.info(f'New event is emitted: {data}'), + on_completed=LOGGER.info('Subscribed for an event: outOfResource'), + on_error=lambda error: LOGGER.info(f'Error for an event outOfResource: {error}') + ) + + # Keep the client awake unless explicitly stopped + while True: + await asyncio.sleep(1) + + +if __name__ == '__main__': + loop = asyncio.new_event_loop() + loop.run_until_complete(main()) diff --git a/examples/coffee-machine/coffee-machine.py b/examples/coffee-machine/coffee-machine.py new file mode 100644 index 0000000..ad2267c --- /dev/null +++ b/examples/coffee-machine/coffee-machine.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +''' +This is an example of Web of Things producer ("server" mode) Thing script. +It considers a fictional smart coffee machine in order to demonstrate the capabilities of Web of Things. +The example is ported from the node-wot environment - +https://github.com/eclipse/thingweb.node-wot/blob/master/packages/examples/src/scripts/coffee-machine.ts. +''' +import asyncio +import json +import logging +import math + +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.servient import Servient + +CATALOGUE_PORT = 9090 +WEBSOCKET_PORT = 9393 +HTTP_PORT = 9494 + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + +TD = { + 'title': 'Smart-Coffee-Machine', + 'id': 'urn:dev:wot:example:coffee-machine', + 'description': '''A smart coffee machine with a range of capabilities. +A complementary tutorial is available at http://www.thingweb.io/smart-coffee-machine.html.''', + 'support': 'git://github.com/eclipse/thingweb.node-wot.git', + '@context': [ + 'https://www.w3.org/2019/wot/td/v1', + ], + 'securityDefinitions': { + 'nosec_sc':{ + 'scheme':'nosec' + } + }, + 'security': 'nosec_sc', + 'properties': { + 'allAvailableResources': { + 'type': 'object', + 'description': '''Current level of all available resources given as an integer percentage for each particular resource. +The data is obtained from the machine's sensors but can be set manually in case the sensors are broken.''', + 'properties': { + 'water': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + 'milk': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + 'chocolate': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + 'coffeeBeans': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 100, + }, + }, + }, + 'possibleDrinks': { + 'type': 'array', + 'description': '''The list of possible drinks in general. Doesn't depend on the available resources.''', + 'items': { + 'type': 'string', + } + }, + 'servedCounter': { + 'type': 'integer', + 'description': '''The total number of served beverages.''', + 'minimum': 0, + }, + 'maintenanceNeeded': { + 'type': 'boolean', + 'description': '''Shows whether a maintenance is needed. The property is observable. Automatically set to True when the servedCounter property exceeds 1000.''', + 'observable': True, + }, + 'schedules': { + 'type': 'array', + 'description': '''The list of scheduled tasks.''', + 'items': { + 'type': 'object', + 'properties': { + 'drinkId': { + 'type': 'string', + 'description': '''Defines what drink to make, drinkId is one of possibleDrinks property values, e.g. latte.''', + }, + 'size': { + 'type': 'string', + 'description': '''Defines the size of a drink, s = small, m = medium, l = large.''', + 'enum': ['s', 'm', 'l'], + }, + 'quantity': { + 'type': 'integer', + 'description': '''Defines how many drinks to make, ranging from 1 to 5.''', + 'minimum': 1, + 'maximum': 5, + }, + 'time': { + 'type': 'string', + 'description': '''Defines the time of the scheduled task in 24h format, e.g. 10:00 or 21:00.''', + }, + 'mode': { + 'type': 'string', + 'description': '''Defines the mode of the scheduled task, e.g. once or everyday. All the possible values are given in the enum field of this Thing Description.''', + 'enum': ['once', 'everyday', 'everyMo', 'everyTu', 'everyWe', 'everyTh', 'everyFr', 'everySat', 'everySun'], + }, + }, + }, + }, + }, + 'actions': { + 'makeDrink': { + 'description': '''Make a drink from available list of beverages. Accepts drink id, size and quantity as input. +Brews one medium americano if no input is specified.''', + 'input': { + 'type': 'object', + 'properties': { + 'drinkId': { + 'type': 'string', + 'description': '''Defines what drink to make, drinkId is one of possibleDrinks property values, e.g. latte.''', + }, + 'size': { + 'type': 'string', + 'description': '''Defines the size of a drink, s = small, m = medium, l = large.''', + 'enum': ['s', 'm', 'l'], + }, + 'quantity': { + 'type': 'integer', + 'description': '''Defines how many drinks to make, ranging from 1 to 5.''', + 'minimum': 1, + 'maximum': 5 + }, + }, + }, + 'output': { + 'type': 'object', + 'description': '''Returns True/false and a message when all invoked promises are resolved (asynchronous).''', + 'properties': { + 'result': { + 'type': 'boolean', + }, + 'message': { + 'type': 'string', + }, + }, + }, + }, + 'setSchedule': { + 'description': '''Add a scheduled task to the schedules property. Accepts drink id, size, quantity, time and mode as body of a request. +Assumes one medium americano if not specified, but time and mode are mandatory fields.''', + 'input': { + 'type': 'object', + 'properties': { + 'drinkId': { + 'type': 'string', + 'description': '''Defines what drink to make, drinkId is one of possibleDrinks property values, e.g. latte.''', + }, + 'size': { + 'type': 'string', + 'description': '''Defines the size of a drink, s = small, m = medium, l = large.''', + 'enum': ['s', 'm', 'l'], + }, + 'quantity': { + 'type': 'integer', + 'description': '''Defines how many drinks to make, ranging from 1 to 5.''', + 'minimum': 1, + 'maximum': 5 + }, + 'time': { + 'type': 'string', + 'description': '''Defines the time of the scheduled task in 24h format, e.g. 10:00 or 21:00.''', + }, + 'mode': { + 'type': 'string', + 'description': '''Defines the mode of the scheduled task, e.g. once or everyday. All the possible values are given in the enum field of this Thing Description.''', + 'enum': ['once', 'everyday', 'everyMo', 'everyTu', 'everyWe', 'everyTh', 'everyFr', 'everySat', 'everySun'], + }, + }, + 'required': ['time', 'mode'], + }, + 'output': { + 'type': 'object', + 'description': '''Returns True/false and a message when all invoked promises are resolved (asynchronous).''', + 'properties': { + 'result': { + 'type': 'boolean', + }, + 'message': { + 'type': 'string', + }, + }, + }, + }, + }, + 'events': { + 'outOfResource': { + 'description': '''Out of resource event. Emitted when the available resource level is not sufficient for a desired drink.''', + 'data': { + 'type': 'string', + }, + }, + }, +} + + +async def main(): + LOGGER.info('Creating WebSocket server on: {}'.format(WEBSOCKET_PORT)) + ws_server = WebsocketServer(port=WEBSOCKET_PORT) + + LOGGER.info('Creating HTTP server on: {}'.format(HTTP_PORT)) + http_server = HTTPServer(port=HTTP_PORT) + + LOGGER.info('Creating servient with TD catalogue on: {}'.format(CATALOGUE_PORT)) + servient = Servient(catalogue_port=CATALOGUE_PORT) + servient.add_server(ws_server) + servient.add_server(http_server) + + LOGGER.info('Starting servient') + wot = await servient.start() + + LOGGER.info('Exposing and configuring Thing') + + # Produce the Thing from Thing Description + exposed_thing = wot.produce(json.dumps(TD)) + + # Initialize the property values + await exposed_thing.properties['allAvailableResources'].write({ + 'water': read_from_sensor('water'), + 'milk': read_from_sensor('milk'), + 'chocolate': read_from_sensor('chocolate'), + 'coffeeBeans': read_from_sensor('coffeeBeans'), + }) + await exposed_thing.properties['possibleDrinks'].write(['espresso', 'americano', 'cappuccino', 'latte', 'hotChocolate', 'hotWater']) + await exposed_thing.properties['maintenanceNeeded'].write(False) + await exposed_thing.properties['schedules'].write([]) + + # # Observe the value of maintenanceNeeded property + exposed_thing.properties['maintenanceNeeded'].subscribe( + + # Notify a "maintainer" when the value has changed + # (the notify function here simply logs a message to the console) + + on_next=lambda data: notify(f'Value changed for an observable property: {data}'), + on_completed=notify('Subscribed for an observable property: maintenanceNeeded'), + on_error=lambda error: notify(f'Error for an observable property maintenanceNeeded: {error}') + ) + + # Override a write handler for servedCounter property, + # raising maintenanceNeeded flag when the value exceeds 1000 drinks + async def served_counter_write_handler(value): + + await exposed_thing._default_update_property_handler('servedCounter', value) + + if value > 1000: + await exposed_thing.properties['maintenanceNeeded'].write(True) + + exposed_thing.set_property_write_handler('servedCounter', served_counter_write_handler) + + # Now initialize the servedCounter property + await exposed_thing.properties['servedCounter'].write(read_from_sensor('servedCounter')) + + # Set up a handler for makeDrink action + async def make_drink_action_handler(params): + params = params['input'] if params['input'] else {} + + # Default values + drinkId = 'americano' + size = 'm' + quantity = 1 + + # Size quantifiers + sizeQuantifiers = {'s': 0.1, 'm': 0.2, 'l': 0.3} + + # Drink recipes showing the amount of a resource consumed for a particular drink + drinkRecipes = { + 'espresso': { + 'water': 1, + 'milk': 0, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'americano': { + 'water': 2, + 'milk': 0, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'cappuccino': { + 'water': 1, + 'milk': 1, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'latte': { + 'water': 1, + 'milk': 2, + 'chocolate': 0, + 'coffeeBeans': 2, + }, + 'hotChocolate': { + 'water': 0, + 'milk': 0, + 'chocolate': 1, + 'coffeeBeans': 0, + }, + 'hotWater': { + 'water': 1, + 'milk': 0, + 'chocolate': 0, + 'coffeeBeans': 0, + }, + } + + # Check if params are provided + drinkId = params.get('drinkId', drinkId) + size = params.get('size', size) + quantity = params.get('quantity', quantity) + + # Read the current level of allAvailableResources + resources = await exposed_thing.read_property('allAvailableResources') + + # Calculate the new level of resources + newResources = resources.copy() + newResources['water'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['water']) + newResources['milk'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['milk']) + newResources['chocolate'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['chocolate']) + newResources['coffeeBeans'] -= math.ceil(quantity * sizeQuantifiers[size] * drinkRecipes[drinkId]['coffeeBeans']) + + # Check if the amount of available resources is sufficient to make a drink + for resource, value in newResources.items(): + if value <= 0: + # Emit outOfResource event + exposed_thing.emit_event('outOfResource', f'Low level of {resource}: {resources[resource]}%') + return {'result': False, 'message': f'{resource} level is not sufficient'} + + # Now store the new level of allAvailableResources and servedCounter + await exposed_thing.properties['allAvailableResources'].write(newResources) + + servedCounter = await exposed_thing.read_property('servedCounter') + servedCounter += quantity + await exposed_thing.properties['servedCounter'].write(servedCounter) + + # Finally deliver the drink + return {'result': True, 'message': f'Your {drinkId} is in progress!'} + + exposed_thing.set_action_handler('makeDrink', make_drink_action_handler) + + # Set up a handler for setSchedule action + async def set_schedule_action_handler(params): + params = params['input'] if params['input'] else {} + + # Check if required fields are provided in input + if 'time' in params and 'mode' in params: + + # Use default values for non-required fields if not provided + params['drinkId'] = params.get('drinkId', 'americano') + params['size'] = params.get('size', 'm') + params['quantity'] = params.get('quantity', 1) + + # Now read the schedules property, add a new schedule to it and then rewrite the schedules property + schedules = await exposed_thing.read_property('schedules') + schedules.append(params) + await exposed_thing.properties['schedules'].write(schedules) + return {'result': True, 'message': 'Your schedule has been set!'} + + return {'result': False, 'message': 'Please provide all the required parameters: time and mode.'} + + exposed_thing.set_action_handler('setSchedule', set_schedule_action_handler) + + exposed_thing.expose() + LOGGER.info(f'{TD["title"]} is ready') + + +def read_from_sensor(sensorType): + # Actual implementation of reading data from a sensor can go here + # For the sake of example, let's just return a value + return 100 + + +def notify(msg, subscribers=['admin@coffeeMachine.com']): + # Actual implementation of notifying subscribers with a message can go here + LOGGER.info(msg) + + +if __name__ == '__main__': + LOGGER.info('Starting loop') + loop = asyncio.new_event_loop() + loop.create_task(main()) + loop.run_forever() diff --git a/examples/cpumonitor/requirements.txt b/examples/cpumonitor/requirements.txt new file mode 100644 index 0000000..f345e34 --- /dev/null +++ b/examples/cpumonitor/requirements.txt @@ -0,0 +1,22 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +psutil==5.6.6 diff --git a/examples/cpumonitor/server.py b/examples/cpumonitor/server.py new file mode 100644 index 0000000..5b15e1a --- /dev/null +++ b/examples/cpumonitor/server.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +WoT application to expose a Thing that provides current host CPU usage levels. +""" + +import asyncio +import json +import logging +import os + +import psutil + +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.mqtt.server import MQTTServer +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.servient import Servient + +logging.basicConfig() +LOGGER = logging.getLogger("sysmonitor") +LOGGER.setLevel(logging.INFO) + +PORT_CATALOGUE = int(os.environ.get("PORT_CATALOGUE", 9090)) +PORT_WS = int(os.environ.get("PORT_WS", 9191)) +PORT_HTTP = int(os.environ.get("PORT_HTTP", 9292)) +MQTT_BROKER = os.environ.get("MQTT_BROKER", "mqtt://localhost") +DEFAULT_CPU_THRESHOLD = float(os.environ.get("CPU_THRESHOLD", 50.0)) +DEFAULT_CPU_CHECK_SEC = float(os.environ.get("CPU_CHECK_SEC", 2.0)) + +DESCRIPTION = { + "id": "urn:org:fundacionctic:thing:cpumonitor", + "name": "CPU Monitor Thing", + "properties": { + "cpuPercent": { + "description": "Current CPU usage", + "type": "number", + "readOnly": True, + "observable": True + }, + "cpuThreshold": { + "description": "CPU usage alert threshold", + "type": "number", + "observable": True + } + }, + "events": { + "cpuAlert": { + "description": "Alert raised when CPU usage goes over the threshold", + "data": { + "type": "number" + } + } + } +} + + +async def cpu_percent_handler(): + """Read handler for the cpuPercent property.""" + + return psutil.cpu_percent(interval=1) + + +def create_cpu_check_task(exposed_thing): + """Launches the task that periodically checks for excessive CPU usage.""" + + async def check_cpu_loop(): + """Coroutine that periodically checks for CPU usage.""" + + while True: + cpu_threshold = await exposed_thing.properties["cpuThreshold"].read() + cpu_percent = await exposed_thing.properties["cpuPercent"].read() + + LOGGER.info("Current CPU usage: {}%".format(cpu_percent)) + + if cpu_percent >= cpu_threshold: + LOGGER.info("Emitting CPU alert event") + exposed_thing.events["cpuAlert"].emit(cpu_percent) + + await asyncio.sleep(DEFAULT_CPU_CHECK_SEC) + + event_loop = asyncio.get_running_loop() + event_loop.create_task(check_cpu_loop()) + + +async def main(): + """Main entrypoint.""" + + LOGGER.info("Creating WebSocket server on: {}".format(PORT_WS)) + + ws_server = WebsocketServer(port=PORT_WS) + + LOGGER.info("Creating HTTP server on: {}".format(PORT_HTTP)) + + http_server = HTTPServer(port=PORT_HTTP) + + LOGGER.info("Creating MQTT server on broker: {}".format(MQTT_BROKER)) + + mqtt_server = MQTTServer(MQTT_BROKER) + + LOGGER.info("Creating servient with TD catalogue on: {}".format(PORT_CATALOGUE)) + + servient = Servient(catalogue_port=PORT_CATALOGUE) + servient.add_server(ws_server) + servient.add_server(http_server) + servient.add_server(mqtt_server) + + LOGGER.info("Starting servient") + + wot = await servient.start() + + LOGGER.info("Exposing System Monitor Thing") + + exposed_thing = wot.produce(json.dumps(DESCRIPTION)) + exposed_thing.set_property_read_handler("cpuPercent", cpu_percent_handler) + exposed_thing.properties["cpuThreshold"].write(DEFAULT_CPU_THRESHOLD) + exposed_thing.expose() + + create_cpu_check_task(exposed_thing) + + +if __name__ == "__main__": + LOGGER.info("Starting loop") + + loop = asyncio.new_event_loop() + loop.create_task(main()) + loop.run_forever() diff --git a/examples/subscriber/client.py b/examples/subscriber/client.py new file mode 100644 index 0000000..2b73fac --- /dev/null +++ b/examples/subscriber/client.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +WoT client application that takes a Thing Description URL and +subscribes to all observable properties and events in the consumed Thing. +""" + +import argparse +import asyncio +import logging + +from wotpy.wot.servient import Servient +from wotpy.wot.wot import WoT + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + + +async def main(td_url, sleep_time): + """Subscribes to all events and properties on the remote Thing.""" + + wot = WoT(servient=Servient()) + consumed_thing = await wot.consume_from_url(td_url) + + LOGGER.info("ConsumedThing: {}".format(consumed_thing)) + + subscriptions = [] + + def subscribe(intrct): + LOGGER.info("Subscribing to: {}".format(intrct)) + + sub = intrct.subscribe( + on_next=lambda item: LOGGER.info("{} :: Next :: {}".format(intrct, item)), + on_completed=lambda: LOGGER.info("{} :: Completed".format(intrct)), + on_error=lambda error: LOGGER.warning("{} :: Error :: {}".format(intrct, error))) + + subscriptions.append(sub) + + for name in consumed_thing.properties: + if consumed_thing.properties[name].observable: + subscribe(consumed_thing.properties[name]) + + for name in consumed_thing.events: + subscribe(consumed_thing.events[name]) + + await asyncio.sleep(sleep_time) + + for subscription in subscriptions: + LOGGER.info("Disposing: {}".format(subscription)) + subscription.dispose() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Subscribes to all events and properties") + parser.add_argument('--url', required=True, help="Thing Description URL") + parser.add_argument('--time', default=120, type=int, help="Total subscription time (s)") + args = parser.parse_args() + + loop = asyncio.get_event_loop() + loop.run_until_complete(main(args.url, args.time)) diff --git a/examples/temperature/server.py b/examples/temperature/server.py new file mode 100644 index 0000000..75b5b5f --- /dev/null +++ b/examples/temperature/server.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +WoT application to expose a Thing that provides simulated temperature values. +""" + +import asyncio +import json +import logging +import random + +from tornado.ioloop import IOLoop, PeriodicCallback + +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.servient import Servient + +CATALOGUE_PORT = 9090 +WEBSOCKET_PORT = 9393 +HTTP_PORT = 9494 + +GLOBAL_TEMPERATURE = None +PERIODIC_MS = 3000 +DEFAULT_TEMP_THRESHOLD = 27.0 + +logging.basicConfig() +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.INFO) + +ID_THING = "urn:temperaturething" +NAME_PROP_TEMP = "temperature" +NAME_PROP_TEMP_THRESHOLD = "high-temperature-threshold" +NAME_EVENT_TEMP_HIGH = "high-temperature" + +DESCRIPTION = { + "id": ID_THING, + "name": ID_THING, + "properties": { + NAME_PROP_TEMP: { + "type": "number", + "readOnly": True, + "observable": True + }, + NAME_PROP_TEMP_THRESHOLD: { + "type": "number", + "observable": True + } + }, + "events": { + NAME_EVENT_TEMP_HIGH: { + "data": { + "type": "number" + } + } + } +} + + +def update_temp(): + """Updates the global temperature value.""" + + global GLOBAL_TEMPERATURE + GLOBAL_TEMPERATURE = round(random.randint(20.0, 30.0) + random.random(), 2) + LOGGER.info("Current temperature: {}".format(GLOBAL_TEMPERATURE)) + + +async def emit_temp_high(exp_thing): + """Emits a 'Temperature High' event if the temperature is over the threshold.""" + + temp_threshold = await exp_thing.read_property(NAME_PROP_TEMP_THRESHOLD) + + if temp_threshold and GLOBAL_TEMPERATURE > temp_threshold: + LOGGER.info("Emitting high temperature event: {}".format(GLOBAL_TEMPERATURE)) + exp_thing.emit_event(NAME_EVENT_TEMP_HIGH, GLOBAL_TEMPERATURE) + + +async def temp_read_handler(): + """Custom handler for the 'Temperature' property.""" + + LOGGER.info("Doing some work to simulate temperature retrieval") + await asyncio.sleep(random.random() * 3.0) + + return GLOBAL_TEMPERATURE + + +async def main(): + update_temp() + + LOGGER.info("Creating WebSocket server on: {}".format(WEBSOCKET_PORT)) + + ws_server = WebsocketServer(port=WEBSOCKET_PORT) + + LOGGER.info("Creating HTTP server on: {}".format(HTTP_PORT)) + + http_server = HTTPServer(port=HTTP_PORT) + + LOGGER.info("Creating servient with TD catalogue on: {}".format(CATALOGUE_PORT)) + + servient = Servient(catalogue_port=CATALOGUE_PORT) + servient.add_server(ws_server) + servient.add_server(http_server) + + LOGGER.info("Starting servient") + + wot = await servient.start() + + LOGGER.info("Exposing and configuring Thing") + + exposed_thing = wot.produce(json.dumps(DESCRIPTION)) + exposed_thing.set_property_read_handler(NAME_PROP_TEMP, temp_read_handler) + await exposed_thing.properties[NAME_PROP_TEMP_THRESHOLD].write(DEFAULT_TEMP_THRESHOLD) + exposed_thing.expose() + + periodic_update = PeriodicCallback(update_temp, PERIODIC_MS) + periodic_update.start() + + async def emit_for_exposed_thing(): + await emit_temp_high(exposed_thing) + + periodic_emit = PeriodicCallback(emit_for_exposed_thing, PERIODIC_MS) + periodic_emit.start() + + +if __name__ == "__main__": + LOGGER.info("Starting loop") + IOLoop.current().add_callback(main) + IOLoop.current().start() diff --git a/pytest-docker-all.sh b/pytest-docker-all.sh new file mode 100755 index 0000000..274cb60 --- /dev/null +++ b/pytest-docker-all.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +: ${PYTEST_ARGS:=" "} + +set -x + +PYTHON_TAG="3.8" PYTEST_ARGS=${PYTEST_ARGS} ./pytest-docker.sh +EXIT_38=$? + +PYTHON_TAG="3.9" PYTEST_ARGS=${PYTEST_ARGS} ./pytest-docker.sh +EXIT_39=$? + +PYTHON_TAG="3.10" PYTEST_ARGS=${PYTEST_ARGS} ./pytest-docker.sh +EXIT_310=$? + +PYTHON_TAG="3.11" PYTEST_ARGS=${PYTEST_ARGS} ./pytest-docker.sh +EXIT_311=$? + +set +x + +RED='\033[0;31m' +GREEN='\033[0;32m' +RESET='\033[0m' + +print_section () { + if [[ $2 != 0 ]] + then + echo -e "$1 :: ${RED}Error${RESET}" + else + echo -e "$1 :: ${GREEN}OK${RESET}" + fi +} + +print_section "Python 3.8" $EXIT_38 +print_section "Python 3.9" $EXIT_39 +print_section "Python 3.10" $EXIT_310 +print_section "Python 3.11" $EXIT_311 diff --git a/pytest-docker.sh b/pytest-docker.sh new file mode 100755 index 0000000..73f814d --- /dev/null +++ b/pytest-docker.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +set -e + +: ${PYTHON_TAG:="3.8"} +: ${PYTEST_ARGS:="-v"} + +echo "Running python tests for version ${PYTHON_TAG} with arguments \"${PYTEST_ARGS}\"" + +CURR_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd )" + +echo "Creating temporary container volume" +VOL_NAME=$(python3 -c "import uuid; print(\"wotpy_tests_{}\".format(uuid.uuid4().hex));") + +docker volume create ${VOL_NAME} + +docker run --rm -it \ + -v ${VOL_NAME}:/vol \ + -v ${CURR_DIR}:/src \ + alpine \ + sh -c "rm -fr /vol/{*,.*} && cp -a /src/. /vol/" + +PYTEST_EXIT_CODE=0 + +echo "Running test container. Environment setup will take a while." + +set -x + +docker run --rm -it \ +-v ${VOL_NAME}:/app \ +-e WOTPY_TESTS_MQTT_BROKER_URL=${WOTPY_TESTS_MQTT_BROKER_URL} \ +python:${PYTHON_TAG} \ +/bin/bash -c "cd /app && pip install --quiet -U .[tests] && pytest ${PYTEST_ARGS}" || PYTEST_EXIT_CODE=$? + +set +x + +docker volume rm ${VOL_NAME} + +exit $PYTEST_EXIT_CODE diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..701bc8a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +filterwarnings = + ; Known deprecation warnings in newer versions of Python + ignore:"@coroutine" decorator is deprecated since Python 3\.8, use "async def" instead:DeprecationWarning + ignore:The loop argument is deprecated since Python 3\.8, and scheduled for removal in Python 3\.10:DeprecationWarning + ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3,\s*and in 3\.[0-9]+ it will stop working:DeprecationWarning + ignore:Using sendmsg\(\) method on sockets returned from get_extra_info\('socket'\) will be prohibited in asyncio 3\.9\.:DeprecationWarning + ignore:Using recvmsg\(\) method on sockets returned from get_extra_info\('socket'\) will be prohibited in asyncio 3\.9\.:DeprecationWarning + + ; Python 3.9 warnings + ignore:The explicit passing of coroutine objects to asyncio\.wait\(\) is deprecated since Python 3\.8, and scheduled for removal in Python 3\.11\.:DeprecationWarning + + ; Python 3.10 warnings + ignore:There is no current event loop:DeprecationWarning \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2b5c488 --- /dev/null +++ b/setup.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import sys +from os import path + +from setuptools import find_packages, setup + +from wotpy.__version__ import __version__ +from wotpy.support import (is_coap_supported, is_dnssd_supported, + is_mqtt_supported) + +install_requires = [ + 'tornado>=6.2,<7.0', + 'jsonschema>=4.17.3,<5.0', + 'reactivex>=4.0.4,<5.0', + 'python-slugify>=8.0.0,<9.0', + 'requests-oauthlib>=1.3.1,<1.4' +] + +test_requires = [ + 'pytest>=7.2.1', + 'pytest-cov>=4.0.0,<5.0.0', + 'pytest-rerunfailures>=11.1.1,<12.0', + 'faker>=17.0.0,<18.0.0', + 'Sphinx>=6.1.3,<7.0.0', + 'sphinx-rtd-theme>=1.2.0,<2.0.0', + 'pyOpenSSL>=23.0.0,<24.0.0', + 'coveralls>=3.3.1,<4.0', + 'coverage>=6.5.0,<7.0', + 'autopep8>=2.0.1,<3.0', + 'rope>=1.7.0,<2.0', + 'bump2version>=1.0,<2.0' +] + +if is_coap_supported(): + install_requires.append('aiocoap[linkheader]==0.4.7') + +if is_mqtt_supported(): + install_requires.append('amqtt==0.11.0b1') + install_requires.append('websockets>=8.0') + +if is_dnssd_supported(): + install_requires.append('zeroconf>=0.47.3,<0.57.0') + test_requires.append('aiozeroconf==0.1.8') + +this_dir = path.abspath(path.dirname(__file__)) + +with open(path.join(this_dir, 'README.md')) as fh: + long_description = fh.read() + +setup( + name='wotpy', + version=__version__, + description='Python implementation of a W3C WoT Runtime and the WoT Scripting API', + long_description=long_description, + long_description_content_type='text/markdown', + keywords='wot iot gateway fog w3c', + author='Andres Garcia Mangas', + author_email='andres.garcia@fundacionctic.org', + url='https://github.com/agmangas/wot-py', + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11' + ], + packages=find_packages(), + include_package_data=True, + install_requires=install_requires, + extras_require={ + 'tests': test_requires, + 'uvloop': ['uvloop>=0.12.2,<0.13.0'] + } +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/codecs/__init__.py b/tests/codecs/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/codecs/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/codecs/test_json.py b/tests/codecs/test_json.py new file mode 100644 index 0000000..2252134 --- /dev/null +++ b/tests/codecs/test_json.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import json + +from tests.utils import assert_equal_dict +from wotpy.codecs.json_codec import JsonCodec + + +def test_json_codec(): + """Content may be serialized to and deserialized from JSON.""" + + test_dict = {'unicode': 'áéíóú', 'ascii': 'hello', 'num': 100} + test_unicode = u'{"unicode": "áéíóú", "ascii": "hello", "num": 100}' + test_bytes = test_unicode.encode('utf8') + + json_codec = JsonCodec() + + dict_from_unicode = json_codec.to_value(test_unicode) + dict_from_bytes = json_codec.to_value(test_bytes) + bytes_from_dict = json_codec.to_bytes(test_dict) + + assert_equal_dict(dict_from_unicode, test_dict, compare_as_unicode=True) + assert_equal_dict(dict_from_bytes, test_dict, compare_as_unicode=True) + + assert isinstance(bytes_from_dict, bytes) + assert_equal_dict(json.loads(bytes_from_dict), test_dict, compare_as_unicode=True) diff --git a/tests/protocols/__init__.py b/tests/protocols/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/protocols/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/protocols/coap/__init__.py b/tests/protocols/coap/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/protocols/coap/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/protocols/coap/conftest.py b/tests/protocols/coap/conftest.py new file mode 100644 index 0000000..4ba9e87 --- /dev/null +++ b/tests/protocols/coap/conftest.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import logging +import uuid + +import pytest +from faker import Faker + +from tests.utils import find_free_port +from wotpy.support import is_coap_supported +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, EventFragmentDict, ActionFragmentDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription +from wotpy.wot.thing import Thing + +collect_ignore = [] + +if not is_coap_supported(): + logging.warning("Skipping CoAP tests due to unsupported platform") + collect_ignore += ["test_server.py", "test_client.py"] + + +@pytest.fixture(params=[{"action_clear_ms": 5000}]) +def coap_server(request): + """Builds a CoAPServer instance that contains an ExposedThing.""" + + from wotpy.protocols.coap.server import CoAPServer + + port = find_free_port() + + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + exposed_thing = ExposedThing(servient=Servient(), thing=thing) + + property_name_01 = uuid.uuid4().hex + exposed_thing.add_property(property_name_01, PropertyFragmentDict({ + "type": "number", + "observable": True + }), value=Faker().pyint()) + + property_name_02 = uuid.uuid4().hex + exposed_thing.add_property(property_name_02, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().pyint()) + + event_name = uuid.uuid4().hex + exposed_thing.add_event(event_name, EventFragmentDict({ + "type": "object" + })) + + action_name = uuid.uuid4().hex + + async def triple(parameters): + input_value = parameters.get("input") + return input_value * 3 + + exposed_thing.add_action(action_name, ActionFragmentDict({ + "input": {"type": "number"}, + "output": {"type": "number"} + }), triple) + + + server = CoAPServer(port=port, **request.param) + server.add_exposed_thing(exposed_thing) + + async def start(): + await server.start() + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + yield server + + async def stop(): + await server.stop() + + loop.run_until_complete(stop()) + + +@pytest.fixture +def coap_servient(): + """Returns a Servient that exposes a CoAP server and one ExposedThing.""" + + from wotpy.protocols.coap.server import CoAPServer + + coap_port = find_free_port() + the_coap_server = CoAPServer(port=coap_port) + + servient = Servient(catalogue_port=None) + servient.add_server(the_coap_server) + + async def start(): + return(await servient.start()) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + wot = loop.run_until_complete(start()) + + property_name = uuid.uuid4().hex + action_name = uuid.uuid4().hex + event_name = uuid.uuid4().hex + + td_dict = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + property_name: { + "observable": True, + "type": "string" + } + }, + "actions": { + action_name: { + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + } + }, + "events": { + event_name: { + "type": "string" + } + } + } + + td = ThingDescription(td_dict) + + exposed_thing = wot.produce(td.to_str()) + exposed_thing.expose() + + async def action_handler(parameters): + input_value = parameters.get("input") + return int(input_value) * 2 + + exposed_thing.set_action_handler(action_name, action_handler) + + yield servient + + async def shutdown(): + await servient.shutdown() + + loop.run_until_complete(shutdown()) diff --git a/tests/protocols/coap/test_client.py b/tests/protocols/coap/test_client.py new file mode 100644 index 0000000..237b516 --- /dev/null +++ b/tests/protocols/coap/test_client.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +from tests.protocols.helpers import \ + client_test_on_property_change, \ + client_test_on_event, \ + client_test_read_property, \ + client_test_write_property, \ + client_test_invoke_action, \ + client_test_invoke_action_error, \ + client_test_on_property_change_error +from wotpy.protocols.coap.client import CoAPClient + + +def test_read_property(coap_servient): + """The CoAP client can read properties.""" + + client_test_read_property(coap_servient, CoAPClient) + + +def test_write_property(coap_servient): + """The CoAP client can write properties.""" + + client_test_write_property(coap_servient, CoAPClient) + + +def test_on_property_change(coap_servient): + """The CoAP client can subscribe to property updates.""" + + client_test_on_property_change(coap_servient, CoAPClient) + + +def test_on_property_change_error(coap_servient): + """Errors that arise in the middle of an ongoing Property + observation are propagated to the subscription as expected.""" + + client_test_on_property_change_error(coap_servient, CoAPClient) + + +def test_invoke_action(coap_servient): + """The CoAP client can invoke actions.""" + + client_test_invoke_action(coap_servient, CoAPClient) + + +def test_invoke_action_error(coap_servient): + """Errors raised by Actions are propagated propertly by the CoAP binding client.""" + + client_test_invoke_action_error(coap_servient, CoAPClient) + + +def test_on_event(coap_servient): + """The CoAP client can subscribe to event emissions.""" + + client_test_on_event(coap_servient, CoAPClient) diff --git a/tests/protocols/coap/test_server.py b/tests/protocols/coap/test_server.py new file mode 100644 index 0000000..d8e641f --- /dev/null +++ b/tests/protocols/coap/test_server.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import datetime +import json + +import aiocoap +import pytest +from faker import Faker + +from tests.utils import find_free_port, run_test_coroutine +from wotpy.protocols.coap.server import CoAPServer +from wotpy.protocols.enums import InteractionVerbs +from wotpy.wot.dictionaries.interaction import ActionFragmentDict + + +def _get_property_href(exp_thing, prop_name, server): + """Helper function to retrieve the Property read/write href.""" + + prop = exp_thing.thing.properties[prop_name] + prop_forms = server.build_forms("127.0.0.1", prop) + return next(item.href for item in prop_forms if InteractionVerbs.READ_PROPERTY == item.op) + + +def _get_property_observe_href(exp_thing, prop_name, server): + """Helper function to retrieve the Property subscription href.""" + + prop = exp_thing.thing.properties[prop_name] + prop_forms = server.build_forms("127.0.0.1", prop) + return next(item.href for item in prop_forms if InteractionVerbs.OBSERVE_PROPERTY == item.op) + + +def _get_action_href(exp_thing, action_name, server): + """Helper function to retrieve the Action invocation href.""" + + action = exp_thing.thing.actions[action_name] + action_forms = server.build_forms("127.0.0.1", action) + return next(item.href for item in action_forms if InteractionVerbs.INVOKE_ACTION == item.op) + + +def _get_event_href(exp_thing, event_name, server): + """Helper function to retrieve the Event subscription href.""" + + event = exp_thing.thing.events[event_name] + event_forms = server.build_forms("127.0.0.1", event) + return next(item.href for item in event_forms if InteractionVerbs.SUBSCRIBE_EVENT == item.op) + + +async def _next_observation(request): + """Yields the next observation for the given CoAP request.""" + + resp = await request.observation.__aiter__().__anext__() + val = json.loads(resp.payload) + return val + + +def test_start_stop(): + """The CoAP server can be started and stopped.""" + + coap_port = find_free_port() + coap_server = CoAPServer(port=coap_port) + ping_uri = "coap://127.0.0.1:{}/.well-known/core".format(coap_port) + + async def ping(): + try: + coap_client = await aiocoap.Context.create_client_context() + request_msg = aiocoap.Message(code=aiocoap.Code.GET, uri=ping_uri) + response = await asyncio.wait_for( + coap_client.request(request_msg).response, + timeout=2) + except Exception: + return False + finally: + await coap_client.shutdown() + + return response.code.is_successful() + + async def test_coroutine(): + assert not (await ping()) + + await coap_server.start() + + assert (await ping()) + assert (await ping()) + + for _ in range(5): + await coap_server.stop() + + assert not (await ping()) + + await coap_server.stop() + + for _ in range(5): + await coap_server.start() + + assert (await ping()) + + run_test_coroutine(test_coroutine) + + +def test_property_read(coap_server): + """Properties exposed in an CoAP server can be read with a CoAP GET request.""" + + exposed_thing = next(coap_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + href = _get_property_href(exposed_thing, prop_name, coap_server) + + async def test_coroutine(): + prop_value = Faker().pyint() + await exposed_thing.properties[prop_name].write(prop_value) + coap_client = await aiocoap.Context.create_client_context() + request_msg = aiocoap.Message(code=aiocoap.Code.GET, uri=href) + response = await coap_client.request(request_msg).response + + assert response.code.is_successful() + assert json.loads(response.payload).get("value") == prop_value + await coap_client.shutdown() + + + run_test_coroutine(test_coroutine) + + +def test_property_write(coap_server): + """Properties exposed in an CoAP server can be updated with a CoAP POST request.""" + + exposed_thing = next(coap_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + href = _get_property_href(exposed_thing, prop_name, coap_server) + + async def test_coroutine(): + value_old = Faker().pyint() + value_new = Faker().pyint() + await exposed_thing.properties[prop_name].write(value_old) + coap_client = await aiocoap.Context.create_client_context() + payload = json.dumps({"value": value_new}).encode("utf-8") + request_msg = aiocoap.Message(code=aiocoap.Code.PUT, payload=payload, uri=href) + response = await coap_client.request(request_msg).response + + assert response.code.is_successful() + assert (await exposed_thing.properties[prop_name].read()) == value_new + await coap_client.shutdown() + + + run_test_coroutine(test_coroutine) + + +def test_property_subscription(coap_server): + """Properties exposed in an CoAP server can be observed for value updates.""" + + exposed_thing = next(coap_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + href = _get_property_observe_href(exposed_thing, prop_name, coap_server) + + future_values = [Faker().pyint() for _ in range(5)] + + async def update_property(): + while True: + await exposed_thing.properties[prop_name].write(future_values[0]) + await asyncio.sleep(0.005) + + def all_values_written(): + return len(future_values) == 0 + + async def test_coroutine(): + task = asyncio.create_task(update_property()) + + coap_client = await aiocoap.Context.create_client_context() + request_msg = aiocoap.Message(code=aiocoap.Code.GET, uri=href, observe=0) + request = coap_client.request(request_msg) + + while not all_values_written(): + payload = await asyncio.ensure_future(_next_observation(request)) + value = payload.get("value") + + try: + future_values.pop(future_values.index(value)) + except ValueError: + pass + + request.observation.cancel() + task.cancel() + await coap_client.shutdown() + + run_test_coroutine(test_coroutine) + + +async def _test_action_invoke(the_coap_server, input_value=None, invocation_sleep=0.05): + """Helper function to invoke an Action in the CoAP server.""" + + exposed_thing = next(the_coap_server.exposed_things) + action_name = next(iter(exposed_thing.thing.actions.keys())) + href = _get_action_href(exposed_thing, action_name, the_coap_server) + + coap_client = await aiocoap.Context.create_client_context() + + input_value = input_value if input_value is not None else Faker().pyint() + payload = json.dumps({"input": input_value}).encode("utf-8") + msg = aiocoap.Message(code=aiocoap.Code.POST, payload=payload, uri=href) + response = await coap_client.request(msg).response + invocation_id = json.loads(response.payload).get("id") + + assert response.code.is_successful() + assert invocation_id + + await asyncio.sleep(invocation_sleep) + + obsv_payload = json.dumps({"id": invocation_id}).encode("utf-8") + obsv_msg = aiocoap.Message(code=aiocoap.Code.GET, payload=obsv_payload, uri=href, observe=0) + obsv_request = coap_client.request(obsv_msg) + obsv_response = await obsv_request.response + + if not obsv_request.observation.cancelled: + obsv_request.observation.cancel() + + await coap_client.shutdown() + + return obsv_response + + +def test_action_invoke(coap_server): + """Actions exposed in a CoAP server can be invoked.""" + + async def test_coroutine(): + input_value = Faker().pyint() + response = await _test_action_invoke(coap_server, input_value=input_value) + data = json.loads(response.payload) + + assert response.code.is_successful() + assert data.get("done") is True + assert data.get("error", None) is None + assert data.get("result") == input_value * 3 + + run_test_coroutine(test_coroutine) + + +@pytest.mark.parametrize("coap_server", [{"action_clear_ms": 5}], indirect=True) +def test_action_clear_invocation(coap_server): + """Completed Action invocations are removed from the CoAP server after a while.""" + + async def test_coroutine(): + invocation_sleep_secs = 0.1 + assert (invocation_sleep_secs * 1000) > coap_server.action_clear_ms + response = await _test_action_invoke(coap_server, invocation_sleep=invocation_sleep_secs) + assert not response.code.is_successful() + + run_test_coroutine(test_coroutine) + + +def test_action_invoke_parallel(coap_server): + """Actions exposed in a CoAP server can be invoked in parallel.""" + + exposed_thing = next(coap_server.exposed_things) + action_name = Faker().pystr() + + handler_futures = {} + + async def handler(parameters): + inp = parameters["input"] + await handler_futures[inp.get("future")] + return inp.get("number") * 3 + + port = coap_server.port + exposed_thing.add_action(action_name, ActionFragmentDict({ + "input": {"type": "object"}, + "output": {"type": "number"} + }), handler) + + href = _get_action_href(exposed_thing, action_name, coap_server) + + async def invoke_action(coap_client): + input_num = Faker().pyint() + future_id = Faker().pystr() + loop = asyncio.get_running_loop() + handler_futures[future_id] = loop.create_future() + + payload = json.dumps({"input": { + "number": input_num, + "future": future_id + }}).encode("utf-8") + + msg = aiocoap.Message(code=aiocoap.Code.POST, payload=payload, uri=href) + response = await coap_client.request(msg).response + assert response.code.is_successful() + invocation_id = json.loads(response.payload).get("id") + + return { + "number": input_num, + "future": future_id, + "id": invocation_id + } + + def build_observe_request(coap_client, invocation): + payload = json.dumps({"id": invocation["id"]}).encode("utf-8") + msg = aiocoap.Message(code=aiocoap.Code.GET, payload=payload, uri=href, observe=0) + return coap_client.request(msg) + + async def test_coroutine(): + coap_client = await aiocoap.Context.create_client_context() + + invocation_01 = await invoke_action(coap_client) + invocation_02 = await invoke_action(coap_client) + + def unblock_01(): + handler_futures[invocation_01["future"]].set_result(True) + + def unblock_02(): + handler_futures[invocation_02["future"]].set_result(True) + + observe_req_01 = build_observe_request(coap_client, invocation_01) + observe_req_02 = build_observe_request(coap_client, invocation_02) + + first_resp_01 = await observe_req_01.response + first_resp_02 = await observe_req_02.response + + assert json.loads(first_resp_01.payload).get("done") is False + assert json.loads(first_resp_02.payload).get("done") is False + + assert not handler_futures[invocation_01["future"]].done() + assert not handler_futures[invocation_02["future"]].done() + + async def wait_for_result(observe_req): + res = None + + while not res or not res.get("done", False): + res = await asyncio.ensure_future(_next_observation(observe_req)) + + return res + + fut_result_01 = asyncio.ensure_future(wait_for_result(observe_req_01)) + fut_result_02 = asyncio.ensure_future(wait_for_result(observe_req_02)) + + unblock_01() + + result_01 = await fut_result_01 + + assert result_01.get("done") is True + assert result_01.get("id") == invocation_01.get("id") + assert result_01.get("result") == invocation_01.get("number") * 3 + + assert not fut_result_02.done() + + unblock_02() + + result_02 = await fut_result_02 + + assert result_02.get("done") is True + assert result_02.get("id") == invocation_02.get("id") + assert result_02.get("result") == invocation_02.get("number") * 3 + + observe_req_01.observation.cancel() + observe_req_02.observation.cancel() + + await coap_client.shutdown() + + run_test_coroutine(test_coroutine) + + +def test_event_subscription(coap_server): + """Event emissions can be observed in a CoAP server.""" + + exposed_thing = next(coap_server.exposed_things) + event_name = next(iter(exposed_thing.thing.events.keys())) + href = _get_event_href(exposed_thing, event_name, coap_server) + + emitted_values = [{ + "num": Faker().pyint(), + "str": Faker().sentence() + } for _ in range(5)] + + async def emit_event(): + while True: + exposed_thing.emit_event(event_name, payload=emitted_values[0]) + await asyncio.sleep(0.005) + + def all_values_emitted(): + return len(emitted_values) == 0 + + async def test_coroutine(): + task = asyncio.create_task(emit_event()) + + coap_client = await aiocoap.Context.create_client_context() + request_msg = aiocoap.Message(code=aiocoap.Code.GET, uri=href, observe=0) + request = coap_client.request(request_msg) + first_response = await request.response + + assert not first_response.payload + + while not all_values_emitted(): + payload = await asyncio.ensure_future(_next_observation(request)) + data = payload["data"] + + assert payload.get("name") == event_name + assert "num" in data + assert "str" in data + + try: + emitted_idx = next( + idx for idx, item in enumerate(emitted_values) + if item["num"] == data["num"] and item["str"] == data["str"]) + + emitted_values.pop(emitted_idx) + except StopIteration: + pass + + request.observation.cancel() + task.cancel() + await coap_client.shutdown() + + run_test_coroutine(test_coroutine) diff --git a/tests/protocols/conftest.py b/tests/protocols/conftest.py new file mode 100644 index 0000000..0c930e9 --- /dev/null +++ b/tests/protocols/conftest.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import os +import ssl +import tempfile +import uuid + +import pytest +from faker import Faker +from OpenSSL import crypto + +from tests.utils import find_free_port +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.support import is_coap_supported, is_mqtt_supported +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription + + +@pytest.fixture +def all_protocols_servient(): + """Returns a Servient configured to use all available protocol bindings.""" + + servient = Servient(catalogue_port=None) + + http_port = find_free_port() + http_server = HTTPServer(port=http_port) + servient.add_server(http_server) + + ws_port = find_free_port() + ws_server = WebsocketServer(port=ws_port) + servient.add_server(ws_server) + + if is_coap_supported(): + from wotpy.protocols.coap.server import CoAPServer + coap_port = find_free_port() + coap_server = CoAPServer(port=coap_port) + servient.add_server(coap_server) + + if is_mqtt_supported(): + from tests.protocols.mqtt.broker import (get_test_broker_url, + is_test_broker_online) + from wotpy.protocols.mqtt.server import MQTTServer + if is_test_broker_online(): + mqtt_server = MQTTServer(broker_url=get_test_broker_url()) + servient.add_server(mqtt_server) + + async def start(): + return(await servient.start()) + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + property_name = uuid.uuid4().hex + title = uuid.uuid4().hex + td_dict = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": title, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + property_name: { + "observable": True, + "type": "string" + } + } + } + + td = ThingDescription(td_dict) + + exposed_thing = wot.produce(td.to_str()) + exposed_thing.expose() + + yield servient + + async def shutdown(): + await servient.shutdown() + + loop.run_until_complete(shutdown()) + + +@pytest.fixture +def self_signed_ssl_context(): + """Returns a self-signed SSL certificate.""" + + base_dir = tempfile.gettempdir() + + certfile = os.path.join(base_dir, "{}.pem".format(uuid.uuid4().hex)) + keyfile = os.path.join(base_dir, "{}.pem".format(uuid.uuid4().hex)) + + pkey = crypto.PKey() + pkey.generate_key(crypto.TYPE_RSA, 2048) + + cert = crypto.X509() + cert.get_subject().C = "ES" + cert.get_subject().ST = Faker().pystr() + cert.get_subject().L = Faker().pystr() + cert.get_subject().O = Faker().pystr() + cert.get_subject().OU = Faker().pystr() + cert.get_subject().CN = Faker().pystr() + cert.set_serial_number(Faker().pyint()) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(3600) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(pkey) + # noinspection PyTypeChecker + cert.sign(pkey, "sha384") + + with open(certfile, "wb") as fh: + fh.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + + with open(keyfile, "wb") as fh: + fh.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)) + + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) + + yield ssl_context + + os.remove(certfile) + os.remove(keyfile) diff --git a/tests/protocols/helpers.py b/tests/protocols/helpers.py new file mode 100644 index 0000000..b07a8f0 --- /dev/null +++ b/tests/protocols/helpers.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import random +import uuid + +from faker import Faker +from reactivex.scheduler.eventloop import IOLoopScheduler +from tornado import ioloop + +from tests.utils import run_test_coroutine +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, EventFragmentDict, ActionFragmentDict +from wotpy.wot.td import ThingDescription + + +def client_test_on_property_change(servient, protocol_client_cls): + """Helper function to test observation of Property updates on bindings clients.""" + + exposed_thing = next(servient.exposed_things) + + prop_name = uuid.uuid4().hex + + exposed_thing.add_property(prop_name, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().sentence()) + + servient.refresh_forms() + + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + protocol_client = protocol_client_cls() + + values = [Faker().sentence() for _ in range(10)] + loop = asyncio.get_running_loop() + values_observed = {value: loop.create_future() for value in values} + + async def periodic_write_next(): + while True: + try: + next_value = next(val for val, fut in values_observed.items() if not fut.done()) + await exposed_thing.properties[prop_name].write(next_value) + except StopIteration: + break + await asyncio.sleep(0.01) + + def on_next(ev): + prop_value = ev.data.value + if prop_value in values_observed and not values_observed[prop_value].done(): + values_observed[prop_value].set_result(True) + + observable = protocol_client.on_property_change(td, prop_name) + + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + subscription = observable.subscribe(on_next, scheduler=scheduler) + + task = asyncio.create_task(periodic_write_next()) + + await asyncio.gather(*values_observed.values()) + + task.cancel() + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def client_test_on_event(servient, protocol_client_cls): + """Helper function to test observation of Events on bindings clients.""" + + exposed_thing = next(servient.exposed_things) + + event_name = uuid.uuid4().hex + + exposed_thing.add_event(event_name, EventFragmentDict({ + "type": "number" + })) + + servient.refresh_forms() + + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + protocol_client = protocol_client_cls() + + payloads = [Faker().pyint() for _ in range(10)] + loop = asyncio.get_running_loop() + future_payloads = {key: loop.create_future() for key in payloads} + + async def periodic_emit_next(): + while True: + try: + next_value = next(val for val, fut in future_payloads.items() if not fut.done()) + exposed_thing.events[event_name].emit(next_value) + except StopIteration: + break + await asyncio.sleep(0.01) + + def on_next(ev): + if ev.data in future_payloads and not future_payloads[ev.data].done(): + future_payloads[ev.data].set_result(True) + + observable = protocol_client.on_event(td, event_name) + + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + subscription = observable.subscribe(on_next, scheduler=scheduler) + + task = asyncio.create_task(periodic_emit_next()) + + await asyncio.gather(*future_payloads.values()) + + task.cancel() + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def client_test_read_property(servient, protocol_client_cls, timeout=None): + """Helper function to test Property reads on bindings clients.""" + + exposed_thing = next(servient.exposed_things) + + prop_name = uuid.uuid4().hex + + exposed_thing.add_property(prop_name, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().sentence()) + + servient.refresh_forms() + + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + protocol_client = protocol_client_cls() + prop_value = Faker().sentence() + + curr_prop_value = await protocol_client.read_property(td, prop_name, timeout=timeout) + + assert curr_prop_value != prop_value + + await exposed_thing.properties[prop_name].write(prop_value) + + curr_prop_value = await protocol_client.read_property(td, prop_name, timeout=timeout) + + assert curr_prop_value == prop_value + + run_test_coroutine(test_coroutine) + + +def client_test_write_property(servient, protocol_client_cls, timeout=None): + """Helper function to test Property writes on bindings clients.""" + + exposed_thing = next(servient.exposed_things) + + prop_name = uuid.uuid4().hex + + exposed_thing.add_property(prop_name, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().sentence()) + + servient.refresh_forms() + + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + protocol_client = protocol_client_cls() + prop_value = Faker().sentence() + + prev_value = await exposed_thing.properties[prop_name].read() + assert prev_value != prop_value + + await protocol_client.write_property(td, prop_name, prop_value, timeout=timeout) + + curr_value = await exposed_thing.properties[prop_name].read() + assert curr_value == prop_value + + run_test_coroutine(test_coroutine) + + +def client_test_invoke_action(servient, protocol_client_cls, timeout=None): + """Helper function to test Action invocations on bindings clients.""" + + exposed_thing = next(servient.exposed_things) + + action_name = uuid.uuid4().hex + + async def action_handler(parameters): + input_value = parameters.get("input") + await asyncio.sleep(random.random() * 0.1) + return("{:f}".format(input_value)) + + exposed_thing.add_action(action_name, ActionFragmentDict({ + "input": {"type": "number"}, + "output": {"type": "string"} + }), action_handler) + + servient.refresh_forms() + + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + protocol_client = protocol_client_cls() + + input_value = Faker().pyint() + + result = await protocol_client.invoke_action(td, action_name, input_value, timeout=timeout) + result_expected = await action_handler({"input": input_value}) + + assert result == result_expected + + run_test_coroutine(test_coroutine) + + +def client_test_invoke_action_error(servient, protocol_client_cls): + """Helper function to test Action invocations that raise errors on bindings clients.""" + + exposed_thing = next(servient.exposed_things) + + action_name = uuid.uuid4().hex + + err_message = Faker().sentence() + + # noinspection PyUnusedLocal + def action_handler(parameters): + raise ValueError(err_message) + + exposed_thing.add_action(action_name, ActionFragmentDict({ + "input": {"type": "number"}, + "output": {"type": "string"} + }), action_handler) + + servient.refresh_forms() + + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + protocol_client = protocol_client_cls() + + try: + await protocol_client.invoke_action(td, action_name, Faker().pyint()) + raise AssertionError("Did not raise Exception") + except Exception as ex: + assert err_message in str(ex) + + run_test_coroutine(test_coroutine) + + +def client_test_on_property_change_error(servient, protocol_client_cls): + """Helper function to test propagation of errors raised + during observation of Property updates on bindings clients.""" + + exposed_thing = next(servient.exposed_things) + + prop_name = uuid.uuid4().hex + + exposed_thing.add_property(prop_name, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().sentence()) + + servient.refresh_forms() + + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + protocol_client = protocol_client_cls() + + await servient.shutdown() + + loop = asyncio.get_running_loop() + future_err = loop.create_future() + + # noinspection PyUnusedLocal + def on_next(item): + future_err.set_exception(Exception("Should not have emitted any items")) + + def on_error(err): + future_err.set_result(err) + + observable = protocol_client.on_property_change(td, prop_name) + + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + subscribe_kwargs = { + "on_next": on_next, + "on_error": on_error, + "scheduler": scheduler + } + + subscription = observable.subscribe(**subscribe_kwargs) + + observe_err = await future_err + + assert isinstance(observe_err, Exception) + + subscription.dispose() + + run_test_coroutine(test_coroutine) diff --git a/tests/protocols/http/__init__.py b/tests/protocols/http/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/protocols/http/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/protocols/http/conftest.py b/tests/protocols/http/conftest.py new file mode 100644 index 0000000..0f3c28e --- /dev/null +++ b/tests/protocols/http/conftest.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import uuid + +import pytest +from faker import Faker + +from tests.utils import find_free_port +from wotpy.protocols.http.server import HTTPServer +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription +from wotpy.wot.thing import Thing + + +@pytest.fixture +def http_server(): + """Builds an HTTPServer instance that contains an ExposedThing.""" + + port = find_free_port() + + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + exposed_thing = ExposedThing(servient=Servient(), thing=thing) + + property_name_01 = uuid.uuid4().hex + exposed_thing.add_property(property_name_01, PropertyFragmentDict({ + "type": "number", + "observable": True + }), value=Faker().pyint()) + + property_name_02 = uuid.uuid4().hex + exposed_thing.add_property(property_name_02, PropertyFragmentDict({ + "type": "number", + "observable": True + }), value=Faker().pyint()) + + event_name = uuid.uuid4().hex + exposed_thing.add_event(event_name, EventFragmentDict({ + "type": "object" + })) + + action_name = uuid.uuid4().hex + + async def triple(parameters): + input_value = parameters.get("input") + await asyncio.sleep(0) + return(input_value * 3) + + exposed_thing.add_action(action_name, ActionFragmentDict({ + "input": {"type": "number"}, + "output": {"type": "number"} + }), triple) + + server = HTTPServer(port=port) + server.add_exposed_thing(exposed_thing) + + async def start(): + await server.start() + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + yield server + + async def stop(): + await server.stop() + + loop.run_until_complete(stop()) + + +@pytest.fixture +def http_servient(): + """Returns a Servient that exposes an HTTP server and one ExposedThing.""" + + http_port = find_free_port() + http_server = HTTPServer(port=http_port) + + servient = Servient(catalogue_port=None) + servient.add_server(http_server) + + async def start(): + return(await servient.start()) + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + property_name_01 = uuid.uuid4().hex + property_name_02 = uuid.uuid4().hex + action_name_01 = uuid.uuid4().hex + event_name_01 = uuid.uuid4().hex + + td_dict = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + property_name_01: { + "observable": True, + "type": "string" + }, + property_name_02: { + "observable": True, + "type": "string" + } + }, + "actions": { + action_name_01: { + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + } + }, + "events": { + event_name_01: { + "type": "string", + } + }, + } + + td = ThingDescription(td_dict) + + exposed_thing = wot.produce(td.to_str()) + exposed_thing.expose() + + async def action_handler(parameters): + input_value = parameters.get("input") + return(int(input_value) * 2) + + exposed_thing.set_action_handler(action_name_01, action_handler) + + yield servient + + async def shutdown(): + await servient.shutdown() + + loop.run_until_complete(shutdown()) diff --git a/tests/protocols/http/test_client.py b/tests/protocols/http/test_client.py new file mode 100644 index 0000000..6a2783a --- /dev/null +++ b/tests/protocols/http/test_client.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +from tests.protocols.helpers import \ + client_test_on_property_change, \ + client_test_on_event, \ + client_test_read_property, \ + client_test_write_property, \ + client_test_invoke_action, \ + client_test_invoke_action_error, \ + client_test_on_property_change_error +from wotpy.protocols.http.client import HTTPClient + + +def test_read_property(http_servient): + """The HTTP client can read properties.""" + + client_test_read_property(http_servient, HTTPClient) + + +def test_write_property(http_servient): + """The HTTP client can write properties.""" + + client_test_write_property(http_servient, HTTPClient) + + +def test_invoke_action(http_servient): + """The HTTP client can invoke actions.""" + + client_test_invoke_action(http_servient, HTTPClient) + + +def test_invoke_action_error(http_servient): + """Errors raised by Actions are propagated propertly by the HTTP binding client.""" + + client_test_invoke_action_error(http_servient, HTTPClient) + + +def test_on_event(http_servient): + """The HTTP client can subscribe to event emissions.""" + + client_test_on_event(http_servient, HTTPClient) + + +def test_on_property_change(http_servient): + """The HTTP client can subscribe to property updates.""" + + client_test_on_property_change(http_servient, HTTPClient) + + +def test_on_property_change_error(http_servient): + """Errors that arise in the middle of an ongoing Property + observation are propagated to the subscription as expected.""" + + client_test_on_property_change_error(http_servient, HTTPClient) diff --git a/tests/protocols/http/test_server.py b/tests/protocols/http/test_server.py new file mode 100644 index 0000000..f7aa536 --- /dev/null +++ b/tests/protocols/http/test_server.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import datetime +import json +import random +import ssl +import uuid +from urllib import parse + +import pytest +import tornado.httpclient +from faker import Faker + +from tests.utils import find_free_port, run_test_coroutine +from wotpy.protocols.enums import InteractionVerbs +from wotpy.protocols.http.enums import HTTPSchemes +from wotpy.protocols.http.server import HTTPServer +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.thing import Thing + +JSON_HEADERS = {"Content-Type": "application/json"} +FORM_URLENCODED_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"} + + +def _get_property_href(exp_thing, prop_name, server): + """Helper function to retrieve the Property read/write href.""" + + prop = exp_thing.thing.properties[prop_name] + prop_forms = server.build_forms("localhost", prop) + return next(item.href for item in prop_forms if InteractionVerbs.READ_PROPERTY in item.op) + + +def _get_property_observe_href(exp_thing, prop_name, server): + """Helper function to retrieve the Property subscription href.""" + + prop = exp_thing.thing.properties[prop_name] + prop_forms = server.build_forms("localhost", prop) + return next(item.href for item in prop_forms if InteractionVerbs.OBSERVE_PROPERTY in item.op) + + +def _get_action_href(exp_thing, action_name, server): + """Helper function to retrieve the Property subscription href.""" + + action = exp_thing.thing.actions[action_name] + action_forms = server.build_forms("localhost", action) + return next(item.href for item in action_forms if InteractionVerbs.INVOKE_ACTION in item.op) + + +def _get_event_observe_href(exp_thing, event_name, server): + """Helper function to retrieve the Event subscription href.""" + + event = exp_thing.thing.events[event_name] + event_forms = server.build_forms("localhost", event) + return next(item.href for item in event_forms if InteractionVerbs.SUBSCRIBE_EVENT in item.op) + + +def test_property_get(http_server): + """Properties exposed in an HTTP server can be read with an HTTP GET request.""" + + exposed_thing = next(http_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + href = _get_property_href(exposed_thing, prop_name, http_server) + + async def test_coroutine(): + prop_value = Faker().pyint() + await exposed_thing.properties[prop_name].write(prop_value) + http_client = tornado.httpclient.AsyncHTTPClient() + http_request = tornado.httpclient.HTTPRequest(href, method="GET") + response = await http_client.fetch(http_request) + + assert json.loads(response.body).get("value") == prop_value + + run_test_coroutine(test_coroutine) + + +def _test_property_set(server, body, prop_value, headers=None): + """Helper function to test Property updates over HTTP.""" + + exposed_thing = next(server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + href = _get_property_href(exposed_thing, prop_name, server) + + async def test_coroutine(): + http_client = tornado.httpclient.AsyncHTTPClient() + http_request = tornado.httpclient.HTTPRequest(href, method="PUT", body=body, headers=headers) + response = await http_client.fetch(http_request) + value = await exposed_thing.properties[prop_name].read() + + assert response.rethrow() is None + assert value == prop_value + + run_test_coroutine(test_coroutine) + + +def test_property_set_form_urlencoded(http_server): + """Properties exposed in an HTTP server can be + updated with an application/x-www-form-urlencoded HTTP PUT request.""" + + prop_value = Faker().pyint() + body = parse.urlencode({"value": prop_value}) + _test_property_set(http_server, body, str(prop_value), headers=FORM_URLENCODED_HEADERS) + + +def test_property_set_json(http_server): + """Properties exposed in an HTTP server can be + updated with an application/json HTTP PUT request.""" + + prop_value = Faker().pyint() + body = json.dumps({"value": prop_value}) + _test_property_set(http_server, body, prop_value, headers=JSON_HEADERS) + + +def test_property_subscribe(http_server): + """Properties exposed in an HTTP server can be subscribed to with an HTTP GET request.""" + + exposed_thing = next(http_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + href = _get_property_observe_href(exposed_thing, prop_name, http_server) + + init_value = Faker().pyint() + prop_value = Faker().pyint() + + assert init_value != prop_value + + async def set_property(): + while True: + await exposed_thing.properties[prop_name].write(prop_value) + await asyncio.sleep(0.01) + + async def test_coroutine(): + await exposed_thing.properties[prop_name].write(init_value) + + task = asyncio.create_task(set_property()) + + http_client = tornado.httpclient.AsyncHTTPClient() + http_request = tornado.httpclient.HTTPRequest(href, method="GET") + response = await http_client.fetch(http_request) + + task.cancel() + + result = json.loads(response.body) + result = result.get("value", result) + assert result == prop_value + + run_test_coroutine(test_coroutine) + + +async def _test_action_run(server, action_handler, input_value): + """Helper to run Action invocation tests.""" + + exposed_thing = next(server.exposed_things) + action_name = next(iter(exposed_thing.thing.actions.keys())) + href = _get_action_href(exposed_thing, action_name, server) + + exposed_thing.set_action_handler(action_name, action_handler) + + body = json.dumps({"input": input_value}) + http_client = tornado.httpclient.AsyncHTTPClient() + http_request = tornado.httpclient.HTTPRequest(href, method="POST", body=body, headers=JSON_HEADERS) + response = await http_client.fetch(http_request) + result = json.loads(response.body) + + return result + + +def test_action_run_success(http_server): + """Actions exposed in an HTTP server can be successfully invoked with an HTTP POST request.""" + + async def test_coroutine(): + async def action_handler(parameters): + return parameters.get("input") * 2 + + input_value = Faker().pyint() + result = await _test_action_run(http_server, action_handler, input_value) + + assert result.get("result") == input_value * 2 + assert result.get("error", None) is None + + run_test_coroutine(test_coroutine) + + +def test_action_run_error(http_server): + """Actions exposed in an HTTP server can raise errors.""" + + async def test_coroutine(): + async def action_handler(parameters): + raise Exception(parameters.get("input")) + + ex_message = Faker().sentence() + result = await _test_action_run(http_server, action_handler, ex_message) + + assert result.get("result", None) is None + assert ex_message in result.get("error") + + run_test_coroutine(test_coroutine) + + +def test_event_subscribe(http_server): + """Events exposed in an HTTP server can be subscribed to with an HTTP GET request.""" + + exposed_thing = next(http_server.exposed_things) + event_name = next(iter(exposed_thing.thing.events.keys())) + href = _get_event_observe_href(exposed_thing, event_name, http_server) + + fake = Faker() + payload = {fake.pystr(): random.choice([fake.pystr(), fake.pyint()]) for _ in range(5)} + + async def emit_event(): + while True: + exposed_thing.emit_event(event_name, payload) + await asyncio.sleep(0.01) + + async def test_coroutine(): + task = asyncio.create_task(emit_event()) + + http_client = tornado.httpclient.AsyncHTTPClient() + http_request = tornado.httpclient.HTTPRequest(href, method="GET") + response = await http_client.fetch(http_request) + + task.cancel() + + assert json.loads(response.body).get("payload") == payload + + run_test_coroutine(test_coroutine) + + +def test_ssl_context(self_signed_ssl_context): + """An SSL context can be passed to the HTTP server to enable encryption.""" + + port = find_free_port() + + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + exposed_thing = ExposedThing(servient=Servient(), thing=thing) + + prop_name = uuid.uuid4().hex + + exposed_thing.add_property(prop_name, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().pystr()) + + + server = HTTPServer(port=port, ssl_context=self_signed_ssl_context) + server.add_exposed_thing(exposed_thing) + + href = _get_property_href(exposed_thing, prop_name, server) + + assert HTTPSchemes.HTTPS in href + + async def test_coroutine(): + await server.start() + + prop_value = Faker().pystr() + await exposed_thing.properties[prop_name].write(prop_value) + http_client = tornado.httpclient.AsyncHTTPClient() + + with pytest.raises(ssl.SSLError): + await http_client.fetch(tornado.httpclient.HTTPRequest(href, method="GET")) + + http_request = tornado.httpclient.HTTPRequest(href, method="GET", validate_cert=False) + response = await http_client.fetch(http_request) + + result = json.loads(response.body) + result = result.get("value", result) + assert result == prop_value + + await server.stop() + + run_test_coroutine(test_coroutine) diff --git a/tests/protocols/mqtt/__init__.py b/tests/protocols/mqtt/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/protocols/mqtt/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/protocols/mqtt/broker.py b/tests/protocols/mqtt/broker.py new file mode 100644 index 0000000..3398196 --- /dev/null +++ b/tests/protocols/mqtt/broker.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import logging +import os + +from amqtt.client import MQTTClient, ConnectException + +from wotpy.protocols.mqtt.enums import MQTTCodesACK + +ENV_BROKER_URL = "WOTPY_TESTS_MQTT_BROKER_URL" +BROKER_SKIP_REASON = "The test MQTT broker is offline" + + +def get_test_broker_url(): + """Returns the MQTT broker URL defined in the environment.""" + + return os.environ.get(ENV_BROKER_URL, None) + + +def is_test_broker_online(): + """Returns True if the MQTT broker defined in the environment is online.""" + + async def check_conn(): + broker_url = get_test_broker_url() + + if not broker_url: + logging.warning("Undefined MQTT broker URL") + return False + + try: + amqtt_client = MQTTClient() + ack_con = await amqtt_client.connect(broker_url) + if ack_con != MQTTCodesACK.CON_OK: + logging.warning("Error ACK on MQTT broker connection: {}".format(ack_con)) + return False + except ConnectException as ex: + logging.warning("MQTT broker connection error: {}".format(ex)) + return False + + return True + + loop = asyncio.get_event_loop_policy().get_event_loop() + conn_ok = loop.run_until_complete(check_conn()) + + if conn_ok is False: + logging.warning( + "Couldn't connect to the test MQTT broker. " + "Please check the {} variable".format(ENV_BROKER_URL)) + + return conn_ok diff --git a/tests/protocols/mqtt/conftest.py b/tests/protocols/mqtt/conftest.py new file mode 100644 index 0000000..de44c6e --- /dev/null +++ b/tests/protocols/mqtt/conftest.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import logging +import random +import uuid + +import pytest +from faker import Faker + +from wotpy.support import is_mqtt_supported +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import ActionFragmentDict, EventFragmentDict, PropertyFragmentDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription +from wotpy.wot.thing import Thing + +collect_ignore = [] + +if not is_mqtt_supported(): + logging.warning("Skipping MQTT tests due to unsupported platform") + collect_ignore += ["test_server.py", "test_client.py"] + + +@pytest.fixture(params=[{"property_callback_ms": None}]) +def mqtt_server(request): + """Builds a MQTTServer instance that contains an ExposedThing.""" + + from wotpy.protocols.mqtt.server import MQTTServer + from tests.protocols.mqtt.broker import get_test_broker_url + + broker_url = get_test_broker_url() + server = MQTTServer(broker_url=broker_url, **request.param) + servient_id = server.servient_id + + thing_name = uuid.uuid4().hex + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": thing_name, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + exposed_thing = ExposedThing(servient=Servient(), thing=thing) + + prop_name = uuid.uuid4().hex + exposed_thing.add_property(prop_name, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().sentence()) + + event_name = uuid.uuid4().hex + exposed_thing.add_event(event_name, EventFragmentDict({ + "type": "number" + })) + + action_name = uuid.uuid4().hex + + async def handler(parameters): + input_value = parameters.get("input") + await asyncio.sleep(random.random() * 0.1) + return("{:f}".format(input_value)) + + exposed_thing.add_action(action_name, ActionFragmentDict({ + "input": {"type": "number"}, + "output": {"type": "string"} + }), handler) + + server.add_exposed_thing(exposed_thing) + + async def start(): + await server.start() + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + yield server + + async def stop(): + await server.stop() + + loop.run_until_complete(stop()) + + +@pytest.fixture +def mqtt_servient(): + """Returns a Servient that exposes a CoAP server and one ExposedThing.""" + + from wotpy.protocols.mqtt.server import MQTTServer + from tests.protocols.mqtt.broker import get_test_broker_url + + broker_url = get_test_broker_url() + server = MQTTServer(broker_url=broker_url) + servient_id = server.servient_id + + servient = Servient(catalogue_port=None) + servient.add_server(server) + + async def start(): + return(await servient.start()) + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + property_name_01 = uuid.uuid4().hex + action_name_01 = uuid.uuid4().hex + event_name_01 = uuid.uuid4().hex + + thing_name = uuid.uuid4().hex + td_dict = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": thing_name, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + property_name_01: { + "observable": True, + "type": "string" + } + }, + "actions": { + action_name_01: { + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + } + }, + "events": { + event_name_01: { + "type": "string" + } + }, + } + + td = ThingDescription(td_dict) + + exposed_thing = wot.produce(td.to_str()) + exposed_thing.expose() + + async def action_handler(parameters): + input_value = parameters.get("input") + return(int(input_value) * 2) + + exposed_thing.set_action_handler(action_name_01, action_handler) + + yield servient + + async def shutdown(): + await servient.shutdown() + + loop.run_until_complete(shutdown()) diff --git a/tests/protocols/mqtt/test_client.py b/tests/protocols/mqtt/test_client.py new file mode 100644 index 0000000..16f4f1a --- /dev/null +++ b/tests/protocols/mqtt/test_client.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import random + +import pytest +from faker import Faker +from unittest.mock import MagicMock, patch + +from tests.protocols.helpers import \ + client_test_on_property_change, \ + client_test_on_event, \ + client_test_read_property, \ + client_test_write_property, \ + client_test_invoke_action, \ + client_test_invoke_action_error +from tests.protocols.mqtt.broker import is_test_broker_online, BROKER_SKIP_REASON +from tests.utils import run_test_coroutine, DEFAULT_TIMEOUT_SECS +from wotpy.protocols.exceptions import ClientRequestTimeout +from wotpy.protocols.mqtt.client import MQTTClient +from wotpy.wot.td import ThingDescription + +pytestmark = pytest.mark.skipif(is_test_broker_online() is False, reason=BROKER_SKIP_REASON) + + +def test_read_property(mqtt_servient): + """Property values may be retrieved using the MQTT binding client.""" + + client_test_read_property(mqtt_servient, MQTTClient) + + +def test_write_property(mqtt_servient): + """Properties may be updated using the MQTT binding client.""" + + client_test_write_property(mqtt_servient, MQTTClient) + + +def test_invoke_action(mqtt_servient): + """Actions may be invoked using the MQTT binding client.""" + + client_test_invoke_action(mqtt_servient, MQTTClient) + + +def test_invoke_action_error(mqtt_servient): + """Errors raised by Actions are propagated propertly by the MQTT binding client.""" + + client_test_invoke_action_error(mqtt_servient, MQTTClient) + + +def test_on_property_change(mqtt_servient): + """Property updates may be observed using the MQTT binding client.""" + + client_test_on_property_change(mqtt_servient, MQTTClient) + + +def test_on_event(mqtt_servient): + """Event emissions may be observed using the MQTT binding client.""" + + client_test_on_event(mqtt_servient, MQTTClient) + + +# noinspection PyUnusedLocal +def _effect_dummy(*args, **kwargs): + """Coroutine mock side effect that does nothing and returns a Mock.""" + + async def _coro(): + await asyncio.sleep(0) + return MagicMock() + + return _coro() + + +# noinspection PyUnusedLocal +def _effect_raise_timeout(*args, **kwargs): + """Coroutine mock side effect that raises a timeout error.""" + + async def _coro(): + await asyncio.sleep(0) + raise asyncio.TimeoutError() + + return _coro() + + +def _build_effect_sleep(sleep_secs): + """Factory function to build coroutine mock side effects to sleep a fixed amount of time.""" + + # noinspection PyUnusedLocal + def _effect_wait(*args, **kwargs): + async def _coro(): + await asyncio.sleep(sleep_secs) + raise asyncio.TimeoutError() + + return _coro() + + return _effect_wait + + +def _build_amqtt_mock(side_effect_deliver_message): + """Returns a mock of the AMQTT Client class.""" + + mock_client = MagicMock() + mock_client.connect.side_effect = _effect_dummy + mock_client.deliver_message.side_effect = side_effect_deliver_message + mock_client.disconnect.side_effect = _effect_dummy + mock_client.subscribe.side_effect = _effect_dummy + mock_client.publish.side_effect = _effect_dummy + + mock_cls = MagicMock() + mock_cls.return_value = mock_client + + return mock_cls + + +def test_timeout_invoke_action(mqtt_servient): + """Timeouts can be defined on Action invocations.""" + + exposed_thing = next(mqtt_servient.exposed_things) + action_name = next(iter(exposed_thing.actions.keys())) + td = ThingDescription.from_thing(exposed_thing.thing) + mqtt_mock = _build_amqtt_mock(_effect_raise_timeout) + + timeout = random.random() + + async def test_coroutine(): + with patch('wotpy.protocols.mqtt.client.amqtt.client.MQTTClient', new=mqtt_mock): + mqtt_client = MQTTClient() + + with pytest.raises(ClientRequestTimeout): + await mqtt_client.invoke_action(td, action_name, Faker().pystr(), timeout=timeout) + + run_test_coroutine(test_coroutine) + + +def test_timeout_read_property(mqtt_servient): + """Timeouts can be defined on Property reads.""" + + exposed_thing = next(mqtt_servient.exposed_things) + prop_name = next(iter(exposed_thing.properties.keys())) + td = ThingDescription.from_thing(exposed_thing.thing) + mqtt_mock = _build_amqtt_mock(_effect_raise_timeout) + + timeout = random.random() + + async def test_coroutine(): + with patch('wotpy.protocols.mqtt.client.amqtt.client.MQTTClient', new=mqtt_mock): + mqtt_client = MQTTClient() + + with pytest.raises(ClientRequestTimeout): + await mqtt_client.read_property(td, prop_name, timeout=timeout) + + run_test_coroutine(test_coroutine) + + +def test_timeout_write_property(mqtt_servient): + """Timeouts can be defined on Property writes.""" + + exposed_thing = next(mqtt_servient.exposed_things) + prop_name = next(iter(exposed_thing.properties.keys())) + td = ThingDescription.from_thing(exposed_thing.thing) + mqtt_mock = _build_amqtt_mock(_effect_raise_timeout) + + timeout = random.random() + + async def test_coroutine(): + with patch('wotpy.protocols.mqtt.client.amqtt.client.MQTTClient', new=mqtt_mock): + mqtt_client = MQTTClient() + + with pytest.raises(ClientRequestTimeout): + await mqtt_client.write_property(td, prop_name, Faker().pystr(), timeout=timeout) + + run_test_coroutine(test_coroutine) + + +def test_stop_timeout(mqtt_servient): + """Attempting to stop an unresponsive connection does not result in an indefinite wait.""" + + exposed_thing = next(mqtt_servient.exposed_things) + prop_name = next(iter(exposed_thing.properties.keys())) + td = ThingDescription.from_thing(exposed_thing.thing) + + timeout = random.random() + + assert (timeout * 3) < DEFAULT_TIMEOUT_SECS + + mqtt_mock = _build_amqtt_mock(_build_effect_sleep(DEFAULT_TIMEOUT_SECS * 10)) + + async def test_coroutine(): + with patch('wotpy.protocols.mqtt.client.amqtt.client.MQTTClient', new=mqtt_mock): + mqtt_client = MQTTClient(stop_loop_timeout_secs=timeout) + + with pytest.raises(ClientRequestTimeout): + await mqtt_client.read_property(td, prop_name, timeout=timeout) + + run_test_coroutine(test_coroutine) diff --git a/tests/protocols/mqtt/test_server.py b/tests/protocols/mqtt/test_server.py new file mode 100644 index 0000000..aee0709 --- /dev/null +++ b/tests/protocols/mqtt/test_server.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import json +import time +import uuid +from asyncio import TimeoutError +import random + +import pytest +from faker import Faker +from amqtt.client import MQTTClient +from amqtt.mqtt.constants import QOS_2, QOS_0 + +from tests.protocols.mqtt.broker import is_test_broker_online, BROKER_SKIP_REASON, get_test_broker_url +from tests.utils import run_test_coroutine +from wotpy.protocols.enums import InteractionVerbs +from wotpy.protocols.mqtt.handlers.action import ActionMQTTHandler +from wotpy.protocols.mqtt.server import MQTTServer +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict + +pytestmark = pytest.mark.skipif(is_test_broker_online() is False, reason=BROKER_SKIP_REASON) + + +def build_topic(server, interaction, interaction_verb): + """Returns the topic for the given interaction and verb.""" + + forms = server.build_forms(None, interaction) + form = next(item for item in forms if interaction_verb == item.op) + return "/".join(form.href.split("/")[3:]) + + +async def connect_broker(topics): + """Connects to the test MQTT broker and subscribes to the topics.""" + + topics = [(topics, QOS_0)] if isinstance(topics, str) else topics + + amqtt_client = MQTTClient() + await amqtt_client.connect(get_test_broker_url()) + await amqtt_client.subscribe(topics) + + return amqtt_client + + +async def _ping(mqtt_server, timeout=None): + """Returns True if the given MQTT server has answered to a PING request.""" + + broker_url = get_test_broker_url() + + topic_ping = "{}/ping".format(mqtt_server.servient_id) + topic_pong = "{}/pong".format(mqtt_server.servient_id) + + try: + amqtt_client = MQTTClient() + await amqtt_client.connect(broker_url) + await amqtt_client.subscribe([(topic_pong, QOS_2)]) + bytes_payload = bytes(uuid.uuid4().hex, "utf8") + await amqtt_client.publish(topic_ping, bytes_payload, qos=QOS_2) + message = await amqtt_client.deliver_message(timeout=timeout) + assert message.data == bytes_payload + await amqtt_client.disconnect() + except TimeoutError: + return False + + return True + + +DEFAULT_PING_TIMEOUT = 1.0 + + +def test_start_stop(): + """The MQTT server may be started and stopped.""" + + mqtt_server = MQTTServer(broker_url=get_test_broker_url()) + + async def test_coroutine(): + assert not (await _ping(mqtt_server, timeout=DEFAULT_PING_TIMEOUT)) + + await mqtt_server.start() + + assert (await _ping(mqtt_server)) + assert (await _ping(mqtt_server)) + + await mqtt_server.stop() + await mqtt_server.start() + await mqtt_server.stop() + + assert not (await _ping(mqtt_server, timeout=DEFAULT_PING_TIMEOUT)) + + await mqtt_server.stop() + await mqtt_server.start() + await mqtt_server.start() + + assert (await _ping(mqtt_server)) + + run_test_coroutine(test_coroutine) + + +def test_servient_id(): + """An MQTT server may be identified by a unique Servient ID to avoid topic collisions.""" + + broker_url = get_test_broker_url() + + mqtt_srv_01 = MQTTServer(broker_url=broker_url) + mqtt_srv_02 = MQTTServer(broker_url=broker_url) + mqtt_srv_03 = MQTTServer(broker_url=broker_url, servient_id=Faker().pystr()) + + assert mqtt_srv_01.servient_id and mqtt_srv_02.servient_id and mqtt_srv_03.servient_id + assert mqtt_srv_01.servient_id == mqtt_srv_02.servient_id + assert mqtt_srv_01.servient_id != mqtt_srv_03.servient_id + + async def assert_ping_loop(srv, num_iters=10): + for _ in range(num_iters): + assert (await _ping(srv, timeout=DEFAULT_PING_TIMEOUT)) + await asyncio.sleep(random.uniform(0.1, 0.3)) + + async def test_coroutine(): + await mqtt_srv_01.start() + await mqtt_srv_03.start() + + await assert_ping_loop(mqtt_srv_01) + await assert_ping_loop(mqtt_srv_03) + + run_test_coroutine(test_coroutine) + + +def test_property_read(mqtt_server): + """Current Property values may be requested using the MQTT binding.""" + + exposed_thing = next(mqtt_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + prop = exposed_thing.thing.properties[prop_name] + topic_read = build_topic(mqtt_server, prop, InteractionVerbs.READ_PROPERTY) + topic_observe = build_topic(mqtt_server, prop, InteractionVerbs.OBSERVE_PROPERTY) + + observe_timeout_secs = 1.0 + + async def test_coroutine(): + prop_value = await exposed_thing.properties[prop_name].read() + + client_read = await connect_broker(topic_read) + client_observe = await connect_broker(topic_observe) + + try: + await client_observe.deliver_message(timeout=observe_timeout_secs) + raise AssertionError('Unexpected message on topic {}'.format(topic_observe)) + except TimeoutError: + pass + + async def read_value(): + while True: + payload = json.dumps({"action": "read"}).encode() + await client_read.publish(topic_read, payload, qos=QOS_2) + await asyncio.sleep(0.05) + + task = asyncio.create_task(read_value()) + + msg = await client_observe.deliver_message() + + task.cancel() + + assert json.loads(msg.data.decode()).get("value") == prop_value + + run_test_coroutine(test_coroutine) + + +def test_property_write(mqtt_server): + """Property values may be updated using the MQTT binding.""" + + exposed_thing = next(mqtt_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + prop = exposed_thing.thing.properties[prop_name] + topic_write = build_topic(mqtt_server, prop, InteractionVerbs.WRITE_PROPERTY) + topic_observe = build_topic(mqtt_server, prop, InteractionVerbs.OBSERVE_PROPERTY) + + async def test_coroutine(): + updated_value = Faker().sentence() + + client_write = await connect_broker(topic_write) + client_observe = await connect_broker(topic_observe) + + loop = asyncio.get_running_loop() + future_observe = loop.create_future() + + async def resolve_future_on_update(): + msg_observe = await client_observe.deliver_message() + assert json.loads(msg_observe.data.decode()).get("value") == updated_value + future_observe.set_result(True) + + asyncio.create_task(resolve_future_on_update()) + + async def publish_write(): + while True: + payload = json.dumps({"action": "write", "value": updated_value}).encode() + await client_write.publish(topic_write, payload, qos=QOS_2) + await asyncio.sleep(0.05) + + task = asyncio.create_task(publish_write()) + + await future_observe + + assert future_observe.result() is True + + task.cancel() + + run_test_coroutine(test_coroutine) + + +CALLBACK_MS = 50 + + +@pytest.mark.parametrize("mqtt_server", [{"property_callback_ms": CALLBACK_MS}], indirect=True) +def test_property_add_remove(mqtt_server): + """The MQTT binding reacts appropriately to Properties + being added and removed from ExposedThings.""" + + exposed_thing = next(mqtt_server.exposed_things) + prop_names = list(exposed_thing.thing.properties.keys()) + + for name in prop_names: + exposed_thing.remove_property(name) + + broker_url = mqtt_server._broker_url + def add_prop(pname): + exposed_thing.add_property(pname, PropertyFragmentDict({ + "type": "number", + "observable": True + }), value=Faker().pyint()) + + def del_prop(pname): + exposed_thing.remove_property(pname) + + async def is_prop_active(prop, timeout_secs=1.0): + topic_observe = build_topic(mqtt_server, prop, InteractionVerbs.OBSERVE_PROPERTY) + topic_write = build_topic(mqtt_server, prop, InteractionVerbs.WRITE_PROPERTY) + + client_observe = await connect_broker(topic_observe) + client_write = await connect_broker(topic_write) + + value = Faker().pyint() + + async def publish_write(): + while True: + payload = json.dumps({"action": "write", "value": value}).encode() + await client_write.publish(topic_write, payload, qos=QOS_0) + await asyncio.sleep(timeout_secs / 4) + + task = asyncio.create_task(publish_write()) + + try: + msg = await client_observe.deliver_message(timeout=timeout_secs) + assert json.loads(msg.data.decode()).get("value") == value + return True + except TimeoutError: + return False + finally: + task.cancel() + + async def test_coroutine(): + sleep_secs = (CALLBACK_MS / 1000.0) * 4 + + prop_01_name = uuid.uuid4().hex + prop_02_name = uuid.uuid4().hex + prop_03_name = uuid.uuid4().hex + + add_prop(prop_01_name) + add_prop(prop_02_name) + add_prop(prop_03_name) + + prop_01 = exposed_thing.thing.properties[prop_01_name] + prop_02 = exposed_thing.thing.properties[prop_02_name] + prop_03 = exposed_thing.thing.properties[prop_03_name] + + await asyncio.sleep(sleep_secs) + + assert (await is_prop_active(prop_01)) + assert (await is_prop_active(prop_02)) + assert (await is_prop_active(prop_03)) + + del_prop(prop_01_name) + + assert not (await is_prop_active(prop_01)) + assert (await is_prop_active(prop_02)) + assert (await is_prop_active(prop_03)) + + del_prop(prop_03_name) + + assert not (await is_prop_active(prop_01)) + assert (await is_prop_active(prop_02)) + assert not (await is_prop_active(prop_03)) + + add_prop(prop_01_name) + + prop_01 = exposed_thing.thing.properties[prop_01_name] + + assert (await is_prop_active(prop_01)) + assert (await is_prop_active(prop_02)) + assert not (await is_prop_active(prop_03)) + + run_test_coroutine(test_coroutine) + + +def test_observe_property_changes(mqtt_server): + """Property updates may be observed using the MQTT binding.""" + + exposed_thing = next(mqtt_server.exposed_things) + prop_name = next(iter(exposed_thing.thing.properties.keys())) + prop = exposed_thing.thing.properties[prop_name] + topic_observe = build_topic(mqtt_server, prop, InteractionVerbs.OBSERVE_PROPERTY) + + async def test_coroutine(): + client_observe = await connect_broker(topic_observe) + + updated_value = Faker().sentence() + + async def write_value(): + while True: + await exposed_thing.properties[prop_name].write(updated_value) + await asyncio.sleep(0.05) + + task = asyncio.create_task(write_value()) + + msg = await client_observe.deliver_message() + + assert json.loads(msg.data.decode()).get("value") == updated_value + + task.cancel() + + run_test_coroutine(test_coroutine) + + +def test_observe_event(mqtt_server): + """Events may be observed using the MQTT binding.""" + + now_ms = int(time.time() * 1000) + + exposed_thing = next(mqtt_server.exposed_things) + event_name = next(iter(exposed_thing.thing.events.keys())) + event = exposed_thing.thing.events[event_name] + topic = build_topic(mqtt_server, event, InteractionVerbs.SUBSCRIBE_EVENT) + + async def test_coroutine(): + client = await connect_broker(topic) + + emitted_value = Faker().pyint() + + async def emit_value(): + while True: + exposed_thing.events[event_name].emit(emitted_value) + await asyncio.sleep(0.05) + + task = asyncio.create_task(emit_value()) + + msg = await client.deliver_message() + + event_data = json.loads(msg.data.decode()) + + assert event_data.get("name") == event_name + assert event_data.get("data") == emitted_value + assert event_data.get("timestamp") >= now_ms + + task.cancel() + + run_test_coroutine(test_coroutine) + + +def test_action_invoke(mqtt_server): + """Actions can be invoked using the MQTT binding.""" + + exposed_thing = next(mqtt_server.exposed_things) + action_name = next(iter(exposed_thing.thing.actions.keys())) + action = exposed_thing.thing.actions[action_name] + + topic_invoke = build_topic(mqtt_server, action, InteractionVerbs.INVOKE_ACTION) + topic_result = ActionMQTTHandler.to_result_topic(topic_invoke) + + async def test_coroutine(): + client_invoke = await connect_broker(topic_invoke) + client_result = await connect_broker(topic_result) + + data = { + "id": uuid.uuid4().hex, + "input": Faker().pyint() + } + + now_ms = int(time.time() * 1000) + + await client_invoke.publish(topic_invoke, json.dumps(data).encode(), qos=QOS_2) + + msg = await client_result.deliver_message() + msg_data = json.loads(msg.data.decode()) + + assert msg_data.get("id") == data.get("id") + assert msg_data.get("result") == "{:f}".format(data.get("input")) + assert msg_data.get("timestamp") >= now_ms + + run_test_coroutine(test_coroutine) + + +def test_action_invoke_error(mqtt_server): + """Action errors are handled appropriately by the MQTT binding.""" + + exposed_thing = next(mqtt_server.exposed_things) + + action_name = uuid.uuid4().hex + err_message = Faker().sentence() + + # noinspection PyUnusedLocal + def handler(parameters): + raise TypeError(err_message) + + broker_url = mqtt_server._broker_url + servient_id = mqtt_server.servient_id + thing_name = exposed_thing.thing.title + exposed_thing.add_action(action_name, ActionFragmentDict({ + "input": {"type": "string"}, + "output": {"type": "string"} + }), handler) + + action = exposed_thing.thing.actions[action_name] + + topic_invoke = build_topic(mqtt_server, action, InteractionVerbs.INVOKE_ACTION) + topic_result = ActionMQTTHandler.to_result_topic(topic_invoke) + + async def test_coroutine(): + client_invoke = await connect_broker(topic_invoke) + client_result = await connect_broker(topic_result) + + data = { + "id": uuid.uuid4().hex, + "input": Faker().pyint() + } + + await client_invoke.publish(topic_invoke, json.dumps(data).encode(), qos=QOS_2) + + msg = await client_result.deliver_message() + msg_data = json.loads(msg.data.decode()) + + assert msg_data.get("id") == data.get("id") + assert msg_data.get("error") == err_message + assert msg_data.get("result", None) is None + + run_test_coroutine(test_coroutine) + + +def test_action_invoke_parallel(mqtt_server): + """Multiple Actions can be invoked in parallel using the MQTT binding.""" + + exposed_thing = next(mqtt_server.exposed_things) + action_name = next(iter(exposed_thing.thing.actions.keys())) + action = exposed_thing.thing.actions[action_name] + + topic_invoke = build_topic(mqtt_server, action, InteractionVerbs.INVOKE_ACTION) + topic_result = ActionMQTTHandler.to_result_topic(topic_invoke) + + num_requests = 10 + + async def test_coroutine(): + client_invoke = await connect_broker(topic_invoke) + client_result = await connect_broker(topic_result) + + requests = [] + + for idx in range(num_requests): + requests.append({ + "id": uuid.uuid4().hex, + "input": Faker().pyint() + }) + + now_ms = int(time.time() * 1000) + + for idx in range(num_requests): + await client_invoke.publish(topic_invoke, json.dumps(requests[idx]).encode(), qos=QOS_2) + + for idx in range(num_requests): + msg = await client_result.deliver_message() + msg_data = json.loads(msg.data.decode()) + expected = next(item for item in requests if item.get("id") == msg_data.get("id")) + + assert msg_data.get("id") == expected.get("id") + assert msg_data.get("result") == "{:f}".format(expected.get("input")) + assert msg_data.get("timestamp") >= now_ms + + run_test_coroutine(test_coroutine) diff --git a/tests/protocols/test_protocols.py b/tests/protocols/test_protocols.py new file mode 100644 index 0000000..f41ed70 --- /dev/null +++ b/tests/protocols/test_protocols.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +from faker import Faker + +from tests.utils import run_test_coroutine +from wotpy.protocols.http.client import HTTPClient +from wotpy.protocols.ws.client import WebsocketClient +from wotpy.support import is_coap_supported, is_mqtt_supported +from wotpy.wot.td import ThingDescription + + +def test_all_protocols_combined(all_protocols_servient): + """Protocol bindings work as expected when multiple + servers are combined within the same Servient.""" + + exposed_thing = next(all_protocols_servient.exposed_things) + td = ThingDescription.from_thing(exposed_thing.thing) + + clients = [ + WebsocketClient(), + HTTPClient() + ] + + if is_coap_supported(): + from wotpy.protocols.coap.client import CoAPClient + clients.append(CoAPClient()) + + if is_mqtt_supported(): + from tests.protocols.mqtt.broker import is_test_broker_online + from wotpy.protocols.mqtt.client import MQTTClient + if is_test_broker_online(): + clients.append(MQTTClient()) + + prop_name = next(iter(td.properties.keys())) + + async def read_property(the_client): + prop_value = Faker().sentence() + + curr_value = await the_client.read_property(td, prop_name) + assert curr_value != prop_value + + await exposed_thing.properties[prop_name].write(prop_value) + + curr_value = await the_client.read_property(td, prop_name) + assert curr_value == prop_value + + async def write_property(the_client): + updated_value = Faker().sentence() + + curr_value = await exposed_thing.properties[prop_name].read() + assert curr_value != updated_value + + await the_client.write_property(td, prop_name, updated_value) + + curr_value = await exposed_thing.properties[prop_name].read() + assert curr_value == updated_value + + async def test_coroutine(): + for client in clients: + await read_property(client) + await write_property(client) + + run_test_coroutine(test_coroutine) diff --git a/tests/protocols/ws/__init__.py b/tests/protocols/ws/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/protocols/ws/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/protocols/ws/conftest.py b/tests/protocols/ws/conftest.py new file mode 100644 index 0000000..42f3a94 --- /dev/null +++ b/tests/protocols/ws/conftest.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import re +import time +import uuid +from urllib.parse import urlparse, urlunparse + +# noinspection PyPackageRequirements +import pytest +import tornado.websocket +# noinspection PyPackageRequirements +from faker import Faker + +from tests.utils import find_free_port +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription +from wotpy.wot.thing import Thing + + +def build_websocket_url(exposed_thing, ws_server, server_port): + """Returns the WS connection URL for the given ExposedThing.""" + + base_url = ws_server.build_base_url(hostname="localhost", thing=exposed_thing.thing) + parsed_url = urlparse(base_url) + test_netloc = re.sub(r':(\d+)$', ':{}'.format(server_port), parsed_url.netloc) + + test_url_parts = list(parsed_url) + test_url_parts[1] = test_netloc + + return urlunparse(test_url_parts) + + +@pytest.fixture +def websocket_server(): + """Builds a WebsocketServer instance with some ExposedThings.""" + + ws_port = find_free_port() + + servient = Servient() + + thing_01_id = uuid.uuid4().urn + thing_01_title = uuid.uuid4().hex + thing_02_id = uuid.uuid4().urn + thing_02_title = uuid.uuid4().hex + + td_json = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + } + + thing_01_json = dict(td_json) + thing_01_json["title"] = thing_01_title + thing_01_json["id"] = thing_01_id + thing_01 = ThingDescription(doc=thing_01_json).build_thing() + exposed_thing_01 = ExposedThing(servient=Servient(), thing=thing_01) + + thing_02_json = dict(td_json) + thing_02_json["title"] = thing_02_title + thing_02_json["id"] = thing_02_id + thing_02 = ThingDescription(doc=thing_02_json).build_thing() + exposed_thing_02 = ExposedThing(servient=Servient(), thing=thing_02) + + prop_name_01 = uuid.uuid4().hex + prop_name_02 = uuid.uuid4().hex + prop_name_03 = uuid.uuid4().hex + event_name_01 = uuid.uuid4().hex + action_name_01 = uuid.uuid4().hex + + prop_value_01 = Faker().sentence() + prop_value_02 = Faker().sentence() + prop_value_03 = Faker().sentence() + + prop_init_01 = PropertyFragmentDict({ + "type": "string", + "observable": True + }) + + prop_init_02 = PropertyFragmentDict({ + "type": "string", + "observable": True + }) + + prop_init_03 = PropertyFragmentDict({ + "type": "string", + "observable": True + }) + + event_init_01 = EventFragmentDict({ + "type": "object" + }) + + action_init_01 = ActionFragmentDict({ + "input": {"type": "string"}, + "output": {"type": "string"} + }) + + def async_lower(parameters): + loop = asyncio.get_running_loop() + input_value = parameters.get("input") + return loop.run_in_executor(None, lambda x: time.sleep(0.1) or x.lower(), input_value) + + exposed_thing_01.add_property(prop_name_01, prop_init_01, value=prop_value_01) + exposed_thing_01.add_property(prop_name_02, prop_init_02, value=prop_value_02) + exposed_thing_01.add_event(event_name_01, event_init_01) + exposed_thing_01.add_action(action_name_01, action_init_01, async_lower) + + exposed_thing_02.add_property(prop_name_03, prop_init_03, value=prop_value_03) + + ws_server = WebsocketServer(port=ws_port) + ws_server.add_exposed_thing(exposed_thing_01) + ws_server.add_exposed_thing(exposed_thing_02) + + async def start(): + await ws_server.start() + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + url_thing_01 = build_websocket_url(exposed_thing_01, ws_server, ws_port) + url_thing_02 = build_websocket_url(exposed_thing_02, ws_server, ws_port) + + yield { + "exposed_thing_01": exposed_thing_01, + "exposed_thing_02": exposed_thing_02, + "prop_name_01": prop_name_01, + "prop_init_01": prop_init_01, + "prop_value_01": prop_value_01, + "prop_name_02": prop_name_02, + "prop_init_02": prop_init_02, + "prop_value_02": prop_value_02, + "prop_name_03": prop_name_03, + "prop_init_03": prop_init_03, + "prop_value_03": prop_value_03, + "event_name_01": event_name_01, + "event_init_01": event_init_01, + "action_name_01": action_name_01, + "action_init_01": action_init_01, + "ws_server": ws_server, + "url_thing_01": url_thing_01, + "url_thing_02": url_thing_02, + "ws_port": ws_port + } + + async def stop(): + await ws_server.stop() + + loop.run_until_complete(stop()) + + +@pytest.fixture +def websocket_servient(): + """Returns a Servient that exposes a Websockets server and one ExposedThing.""" + + ws_port = find_free_port() + ws_server = WebsocketServer(port=ws_port) + + servient = Servient(catalogue_port=None) + servient.add_server(ws_server) + + async def start(): + return(await servient.start()) + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + property_name_01 = uuid.uuid4().hex + property_name_02 = uuid.uuid4().hex + action_name_01 = uuid.uuid4().hex + event_name_01 = uuid.uuid4().hex + + title = uuid.uuid4().hex + td_dict = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": title, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + property_name_01: { + "observable": True, + "type": "string" + }, + property_name_02: { + "observable": True, + "type": "string" + } + }, + "actions": { + action_name_01: { + "input": { + "type": "object" + }, + "output": { + "type": "string" + } + } + }, + "events": { + event_name_01: { + "type": "string" + } + } + } + + td = ThingDescription(td_dict) + + exposed_thing = wot.produce(td.to_str()) + exposed_thing.expose() + + async def action_handler(parameters): + input_value = parameters.get("input") + arg_b = input_value.get("arg_b") or uuid.uuid4().hex + return(input_value.get("arg_a") + arg_b) + + exposed_thing.set_action_handler(action_name_01, action_handler) + + yield servient + + async def shutdown(): + await servient.shutdown() + + loop.run_until_complete(shutdown()) diff --git a/tests/protocols/ws/test_client.py b/tests/protocols/ws/test_client.py new file mode 100644 index 0000000..323883f --- /dev/null +++ b/tests/protocols/ws/test_client.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import random +import uuid + +import pytest +import tornado.websocket +from unittest.mock import patch +from reactivex.scheduler.eventloop import IOLoopScheduler +from tornado import ioloop + +from tests.protocols.helpers import \ + client_test_on_event, \ + client_test_read_property, \ + client_test_write_property, \ + client_test_invoke_action, \ + client_test_invoke_action_error, \ + client_test_on_property_change_error +from tests.utils import run_test_coroutine +from wotpy.protocols.exceptions import ProtocolClientException, ClientRequestTimeout +from wotpy.protocols.ws.client import WebsocketClient +from wotpy.wot.td import ThingDescription + + +def test_read_property(websocket_servient): + """The Websockets client can read properties.""" + + client_test_read_property(websocket_servient, WebsocketClient) + + +def test_read_property_unknown(websocket_servient): + """The Websockets client raises an error when attempting to read an unknown property.""" + + exposed_thing = next(websocket_servient.exposed_things) + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + ws_client = WebsocketClient() + + with pytest.raises(ProtocolClientException): + await ws_client.read_property(td, uuid.uuid4().hex) + + run_test_coroutine(test_coroutine) + + +def test_write_property(websocket_servient): + """The Websockets client can write properties.""" + + client_test_write_property(websocket_servient, WebsocketClient) + + +def test_invoke_action(websocket_servient): + """The Websockets client can invoke actions.""" + + client_test_invoke_action(websocket_servient, WebsocketClient) + + +def test_invoke_action_error(websocket_servient): + """Errors raised by Actions are propagated propertly by the WebSockets binding client.""" + + client_test_invoke_action_error(websocket_servient, WebsocketClient) + + +def test_on_event(websocket_servient): + """The Websockets client can observe events.""" + + client_test_on_event(websocket_servient, WebsocketClient) + + +def test_on_property_change(websocket_servient): + """The Websockets client can observe property changes.""" + + exposed_thing = next(websocket_servient.exposed_things) + td = ThingDescription.from_thing(exposed_thing.thing) + + async def test_coroutine(): + ws_client = WebsocketClient() + + prop_names = list(td.properties.keys()) + prop_name_01 = prop_names[0] + prop_name_02 = prop_names[1] + + obsv_01 = ws_client.on_property_change(td, prop_name_01) + obsv_02 = ws_client.on_property_change(td, prop_name_02) + + prop_values_01 = [uuid.uuid4().hex for _ in range(10)] + prop_values_02 = [uuid.uuid4().hex for _ in range(90)] + + loop = asyncio.get_running_loop() + future_values_01 = {key: loop.create_future() for key in prop_values_01} + future_values_02 = {key: loop.create_future() for key in prop_values_02} + + future_conn_01 = loop.create_future() + future_conn_02 = loop.create_future() + + def build_on_next(fut_conn, fut_vals): + def on_next(ev): + if not fut_conn.done(): + fut_conn.set_result(True) + + if ev.data.value in fut_vals: + fut_vals[ev.data.value].set_result(True) + + return on_next + + on_next_01 = build_on_next(future_conn_01, future_values_01) + on_next_02 = build_on_next(future_conn_02, future_values_02) + + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + subscription_01 = obsv_01.subscribe(on_next_01, scheduler=scheduler) + subscription_02 = obsv_02.subscribe(on_next_02, scheduler=scheduler) + + while not future_conn_01.done() or not future_conn_02.done(): + await exposed_thing.write_property(prop_name_01, uuid.uuid4().hex) + await exposed_thing.write_property(prop_name_02, uuid.uuid4().hex) + await asyncio.sleep(0) + + assert len(prop_values_01) < len(prop_values_02) + + for idx in range(len(prop_values_01)): + await exposed_thing.write_property(prop_name_01, prop_values_01[idx]) + await exposed_thing.write_property(prop_name_02, prop_values_02[idx]) + + await asyncio.gather(*future_values_01.values()) + + assert next(fut for fut in future_values_02.values() if not fut.done()) + + subscription_01.dispose() + + for val in prop_values_02[len(prop_values_01):]: + await exposed_thing.write_property(prop_name_02, val) + + await asyncio.gather(*future_values_02.values()) + + subscription_02.dispose() + + run_test_coroutine(test_coroutine) + + +def test_on_property_change_error(websocket_servient): + """Errors that arise in the middle of an ongoing Property + observation are propagated to the subscription as expected.""" + + client_test_on_property_change_error(websocket_servient, WebsocketClient) + + +# noinspection PyUnusedLocal +def _condition_coro(*args, **kwargs): + """Coroutine mock side effect that returns a Condition that is never notified.""" + + async def _coro(): + return asyncio.Condition() + + return _coro() + + +def test_timeout_read_property(websocket_servient): + """Timeouts can be defined on Property reads.""" + + # noinspection PyUnresolvedReferences + with patch.object(WebsocketClient, '_send_message', _condition_coro): + with pytest.raises(ClientRequestTimeout): + client_test_read_property(websocket_servient, WebsocketClient, timeout=random.random()) + + +def test_timeout_write_property(websocket_servient): + """Timeouts can be defined on Property writes.""" + + # noinspection PyUnresolvedReferences + with patch.object(WebsocketClient, '_send_message', _condition_coro): + with pytest.raises(ClientRequestTimeout): + client_test_write_property(websocket_servient, WebsocketClient, timeout=random.random()) + + +def test_timeout_invoke_action(websocket_servient): + """Timeouts can be defined on Action invocations.""" + + # noinspection PyUnresolvedReferences + with patch.object(WebsocketClient, '_send_message', _condition_coro): + with pytest.raises(ClientRequestTimeout): + client_test_invoke_action(websocket_servient, WebsocketClient, timeout=random.random()) diff --git a/tests/protocols/ws/test_server.py b/tests/protocols/ws/test_server.py new file mode 100644 index 0000000..b288774 --- /dev/null +++ b/tests/protocols/ws/test_server.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import datetime +import ssl +import uuid + +import pytest +import tornado.httpclient +import tornado.websocket +from faker import Faker + +from tests.protocols.ws.conftest import build_websocket_url +from tests.utils import find_free_port, run_test_coroutine +from wotpy.protocols.ws.enums import WebsocketMethods, WebsocketErrors, WebsocketSchemes +from wotpy.protocols.ws.messages import \ + WebsocketMessageRequest, \ + WebsocketMessageResponse, \ + WebsocketMessageError, \ + WebsocketMessageEmittedItem +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.thing import Thing + + +def test_thing_not_found(websocket_server): + """The socket is automatically closed when connecting to an unknown thing.""" + + ws_port = websocket_server.pop("ws_port") + + async def test_coroutine(): + url_unknown = "ws://localhost:{}/{}".format(ws_port, uuid.uuid4().hex) + conn = await tornado.websocket.websocket_connect(url_unknown) + msg = await conn.read_message() + + assert msg is None + + run_test_coroutine(test_coroutine) + + +def test_read_property(websocket_server): + """Properties can be retrieved using Websockets.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + url_thing_02 = websocket_server.pop("url_thing_02") + prop_name_01 = websocket_server.pop("prop_name_01") + prop_name_02 = websocket_server.pop("prop_name_02") + prop_name_03 = websocket_server.pop("prop_name_03") + prop_value_01 = websocket_server.pop("prop_value_01") + prop_value_02 = websocket_server.pop("prop_value_02") + prop_value_03 = websocket_server.pop("prop_value_03") + + async def test_coroutine(): + conns = [ + await tornado.websocket.websocket_connect(url_thing_01), + await tornado.websocket.websocket_connect(url_thing_02) + ] + + request_id_01 = Faker().pyint() + request_id_02 = Faker().pyint() + request_id_03 = Faker().pyint() + + ws_request_prop_01 = WebsocketMessageRequest( + method=WebsocketMethods.READ_PROPERTY, + params={"name": prop_name_01}, + msg_id=request_id_01) + + ws_request_prop_02 = WebsocketMessageRequest( + method=WebsocketMethods.READ_PROPERTY, + params={"name": prop_name_02}, + msg_id=request_id_02) + + ws_request_prop_03 = WebsocketMessageRequest( + method=WebsocketMethods.READ_PROPERTY, + params={"name": prop_name_03}, + msg_id=request_id_03) + + conns[0].write_message(ws_request_prop_01.to_json()) + conns[0].write_message(ws_request_prop_02.to_json()) + conns[1].write_message(ws_request_prop_03.to_json()) + + raw_resp_01 = await conns[0].read_message() + raw_resp_02 = await conns[0].read_message() + raw_resp_03 = await conns[1].read_message() + + ws_resp_01 = WebsocketMessageResponse.from_raw(raw_resp_01) + ws_resp_02 = WebsocketMessageResponse.from_raw(raw_resp_02) + ws_resp_03 = WebsocketMessageResponse.from_raw(raw_resp_03) + + assert ws_resp_01.result == prop_value_01 + assert ws_resp_02.result == prop_value_02 + assert ws_resp_03.result == prop_value_03 + + conns[0].close() + conns[1].close() + + run_test_coroutine(test_coroutine) + + +def test_write_property(websocket_server): + """Properties can be updated using Websockets.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + exposed_thing_01 = websocket_server.pop("exposed_thing_01") + prop_name = websocket_server.pop("prop_name_01") + + async def test_coroutine(): + conn = await tornado.websocket.websocket_connect(url_thing_01) + + updated_value = Faker().pystr() + msg_id = uuid.uuid4().hex + + ws_request = WebsocketMessageRequest( + method=WebsocketMethods.WRITE_PROPERTY, + params={"name": prop_name, "value": updated_value}, + msg_id=msg_id) + + value = await exposed_thing_01.read_property(prop_name) + + assert value != updated_value + + conn.write_message(ws_request.to_json()) + raw_response = await conn.read_message() + ws_response = WebsocketMessageResponse.from_raw(raw_response) + + assert ws_response.id == msg_id + + value = await exposed_thing_01.read_property(prop_name) + + assert value == updated_value + + ws_request_err = WebsocketMessageRequest( + method=WebsocketMethods.WRITE_PROPERTY, + params={"name": prop_name + Faker().pystr(), "value": updated_value}, + msg_id=msg_id) + + conn.write_message(ws_request_err.to_json()) + raw_error = await conn.read_message() + ws_error = WebsocketMessageError.from_raw(raw_error) + + assert ws_error.code + + conn.close() + + run_test_coroutine(test_coroutine) + + +def test_invoke_action(websocket_server): + """Actions can be invoked using Websockets.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + exposed_thing_01 = websocket_server.pop("exposed_thing_01") + action_name = websocket_server.pop("action_name_01") + + async def test_coroutine(): + conn = await tornado.websocket.websocket_connect(url_thing_01) + + input_val = Faker().pystr() + + expected_out = await exposed_thing_01.invoke_action(action_name, input_val) + + msg_id = Faker().pyint() + + msg_invoke_req = WebsocketMessageRequest( + method=WebsocketMethods.INVOKE_ACTION, + params={"name": action_name, "parameters": input_val}, + msg_id=msg_id) + + conn.write_message(msg_invoke_req.to_json()) + + msg_invoke_resp_raw = await conn.read_message() + msg_invoke_resp = WebsocketMessageResponse.from_raw(msg_invoke_resp_raw) + + assert msg_invoke_resp.id == msg_id + assert msg_invoke_resp.result == expected_out + + conn.close() + + run_test_coroutine(test_coroutine) + + +def test_on_property_change(websocket_server): + """Property changes can be observed using Websockets.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + exposed_thing_01 = websocket_server.pop("exposed_thing_01") + prop_name = websocket_server.pop("prop_name_01") + + async def test_coroutine(): + observe_msg_id = Faker().pyint() + + updated_val_01 = Faker().pystr() + updated_val_02 = Faker().pystr() + updated_val_03 = Faker().pystr() + + conn = await tornado.websocket.websocket_connect(url_thing_01) + + msg_observe_req = WebsocketMessageRequest( + method=WebsocketMethods.ON_PROPERTY_CHANGE, + params={"name": prop_name}, + msg_id=observe_msg_id) + + conn.write_message(msg_observe_req.to_json()) + + msg_observe_resp_raw = await conn.read_message() + msg_observe_resp = WebsocketMessageResponse.from_raw(msg_observe_resp_raw) + + assert msg_observe_resp.id == observe_msg_id + + subscription_id = msg_observe_resp.result + + def assert_emitted(the_msg_raw, the_expected_val): + msg_emitted = WebsocketMessageEmittedItem.from_raw(the_msg_raw) + + assert msg_emitted.subscription_id == subscription_id + assert msg_emitted.data["name"] == prop_name + assert msg_emitted.data["value"] == the_expected_val + + await exposed_thing_01.write_property(prop_name, updated_val_01) + + msg_emitted_raw = await conn.read_message() + assert_emitted(msg_emitted_raw, updated_val_01) + + await exposed_thing_01.write_property(prop_name, updated_val_02) + await exposed_thing_01.write_property(prop_name, updated_val_03) + + msg_emitted_raw = await conn.read_message() + assert_emitted(msg_emitted_raw, updated_val_02) + + msg_emitted_raw = await conn.read_message() + assert_emitted(msg_emitted_raw, updated_val_03) + + conn.close() + + run_test_coroutine(test_coroutine) + + +def test_on_undefined_property_change(websocket_server): + """Observing an undefined property results in a subscription error message.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + + async def test_coroutine(): + observe_msg_id = Faker().pyint() + prop_name_err = uuid.uuid4().hex + + conn = await tornado.websocket.websocket_connect(url_thing_01) + + msg_observe_req = WebsocketMessageRequest( + method=WebsocketMethods.ON_PROPERTY_CHANGE, + params={"name": prop_name_err}, + msg_id=observe_msg_id) + + conn.write_message(msg_observe_req.to_json()) + + msg_observe_resp_raw = await conn.read_message() + msg_observe_resp = WebsocketMessageResponse.from_raw(msg_observe_resp_raw) + + msg_observe_err_raw = await conn.read_message() + msg_observe_err = WebsocketMessageError.from_raw(msg_observe_err_raw) + + assert msg_observe_err.code == WebsocketErrors.SUBSCRIPTION_ERROR + assert msg_observe_err.data["subscription"] == msg_observe_resp.result + + run_test_coroutine(test_coroutine) + + +def test_on_event(websocket_server): + """Events can be observed using Websockets.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + exposed_thing_01 = websocket_server.pop("exposed_thing_01") + event_name = websocket_server.pop("event_name_01") + + async def test_coroutine(): + observe_msg_id = Faker().pyint() + payload_01 = Faker().pydict(10, True, [str, float]) + payload_02 = Faker().pydict(10, True, [str, float]) + payload_03 = Faker().pydict(10, True, [int]) + + conn = await tornado.websocket.websocket_connect(url_thing_01) + + msg_observe_req = WebsocketMessageRequest( + method=WebsocketMethods.ON_EVENT, + params={"name": event_name}, + msg_id=observe_msg_id) + + conn.write_message(msg_observe_req.to_json()) + + msg_observe_resp_raw = await conn.read_message() + msg_observe_resp = WebsocketMessageResponse.from_raw(msg_observe_resp_raw) + + assert msg_observe_resp.id == observe_msg_id + + subscription_id = msg_observe_resp.result + + def assert_emitted(the_msg_raw, the_expected_payload): + msg_emitted = WebsocketMessageEmittedItem.from_raw(the_msg_raw) + + assert msg_emitted.subscription_id == subscription_id + assert msg_emitted.data == the_expected_payload + + exposed_thing_01.emit_event(event_name, payload_01) + + msg_emitted_raw = await conn.read_message() + assert_emitted(msg_emitted_raw, payload_01) + + exposed_thing_01.emit_event(event_name, payload_02) + exposed_thing_01.emit_event(event_name, payload_03) + + msg_emitted_raw = await conn.read_message() + assert_emitted(msg_emitted_raw, payload_02) + + msg_emitted_raw = await conn.read_message() + assert_emitted(msg_emitted_raw, payload_03) + + conn.close() + + run_test_coroutine(test_coroutine) + + +def test_on_undefined_event(websocket_server): + """Observing an undefined event results in a subscription error message.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + + async def test_coroutine(): + observe_msg_id = Faker().pyint() + event_name_err = Faker().pystr() + + conn = await tornado.websocket.websocket_connect(url_thing_01) + + msg_observe_req = WebsocketMessageRequest( + method=WebsocketMethods.ON_EVENT, + params={"name": event_name_err}, + msg_id=observe_msg_id) + + conn.write_message(msg_observe_req.to_json()) + + msg_observe_resp_raw = await conn.read_message() + msg_observe_resp = WebsocketMessageResponse.from_raw(msg_observe_resp_raw) + + msg_observe_err_raw = await conn.read_message() + msg_observe_err = WebsocketMessageError.from_raw(msg_observe_err_raw) + + assert msg_observe_err.code == WebsocketErrors.SUBSCRIPTION_ERROR + assert msg_observe_err.data["subscription"] == msg_observe_resp.result + + run_test_coroutine(test_coroutine) + + +def test_dispose(websocket_server): + """Observable subscriptions can be disposed using Websockets.""" + + url_thing_01 = websocket_server.pop("url_thing_01") + exposed_thing_01 = websocket_server.pop("exposed_thing_01") + prop_name = websocket_server.pop("prop_name_01") + + async def test_coroutine(): + observe_msg_id = Faker().pyint() + dispose_msg_id = Faker().pyint() + + conn = await tornado.websocket.websocket_connect(url_thing_01) + + msg_observe_req = WebsocketMessageRequest( + method=WebsocketMethods.ON_PROPERTY_CHANGE, + params={"name": prop_name}, + msg_id=observe_msg_id) + + conn.write_message(msg_observe_req.to_json()) + + msg_observe_resp_raw = await conn.read_message() + msg_observe_resp = WebsocketMessageResponse.from_raw(msg_observe_resp_raw) + + assert msg_observe_resp.id == observe_msg_id + + subscription_id = msg_observe_resp.result + + await exposed_thing_01.write_property(prop_name, Faker().sentence()) + + msg_emitted_raw = await conn.read_message() + msg_emitted = WebsocketMessageEmittedItem.from_raw(msg_emitted_raw) + + assert msg_emitted.subscription_id == subscription_id + + msg_dispose_req = WebsocketMessageRequest( + method=WebsocketMethods.DISPOSE, + params={"subscription": subscription_id}, + msg_id=dispose_msg_id) + + conn.write_message(msg_dispose_req.to_json()) + + msg_dispose_resp_raw = await conn.read_message() + msg_dispose_resp = WebsocketMessageResponse.from_raw(msg_dispose_resp_raw) + + assert msg_dispose_resp.result == subscription_id + + conn.write_message(msg_dispose_req.to_json()) + + msg_dispose_resp_02_raw = await conn.read_message() + msg_dispose_resp_02 = WebsocketMessageResponse.from_raw(msg_dispose_resp_02_raw) + + assert not msg_dispose_resp_02.result + + await exposed_thing_01.write_property(prop_name, Faker().pystr()) + await exposed_thing_01.write_property(prop_name, Faker().pystr()) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for( + conn.read_message(), + timeout=0.2) + + run_test_coroutine(test_coroutine) + + +def test_ssl_context(self_signed_ssl_context): + """An SSL context can be passed to the WebSockets server to enable encryption.""" + + port = find_free_port() + + title = uuid.uuid4().hex + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": title, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + exposed_thing = ExposedThing(servient=Servient(), thing=thing) + + prop_name = uuid.uuid4().hex + + exposed_thing.add_property(prop_name, PropertyFragmentDict({ + "type": "string", + "observable": True + }), value=Faker().pystr()) + + + server = WebsocketServer(port=port, ssl_context=self_signed_ssl_context) + server.add_exposed_thing(exposed_thing) + + async def test_coroutine(): + await server.start() + + ws_url = build_websocket_url(exposed_thing, server, port) + + assert WebsocketSchemes.WSS in ws_url + + with pytest.raises(ssl.SSLError): + http_req = tornado.httpclient.HTTPRequest(ws_url, method="GET") + await tornado.websocket.websocket_connect(http_req) + + http_req = tornado.httpclient.HTTPRequest(ws_url, method="GET", validate_cert=False) + conn = await tornado.websocket.websocket_connect(http_req) + + request_id = Faker().pyint() + + msg_req = WebsocketMessageRequest( + method=WebsocketMethods.READ_PROPERTY, + params={"name": prop_name}, + msg_id=request_id) + + conn.write_message(msg_req.to_json()) + + msg_resp_raw = await conn.read_message() + msg_resp = WebsocketMessageResponse.from_raw(msg_resp_raw) + + assert msg_resp.id == request_id + + value = await exposed_thing.read_property(prop_name) + + assert value == msg_resp.result + + conn.close() + await server.stop() + + run_test_coroutine(test_coroutine) diff --git a/tests/td_examples.py b/tests/td_examples.py new file mode 100644 index 0000000..fec2a88 --- /dev/null +++ b/tests/td_examples.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 + +TD_EXAMPLE = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1 + ], + "id": "urn:dev:wot:com:example:servient:lamp", + "title": "MyLampThing", + "description": "MyLampThing uses JSON-LD 1.1 serialization", + "security": ["nosec_sc"], + "securityDefinitions": { + "nosec_sc": {"scheme": "nosec"} + }, + "properties": { + "status": { + "description": "Shows the current status of the lamp", + "type": "string", + "forms": [{ + "href": "coaps://mylamp.example.com/status" + }] + } + }, + "actions": { + "toggle": { + "description": "Turn on or off the lamp", + "forms": [{ + "href": "coaps://mylamp.example.com/toggle" + }] + } + }, + "events": { + "overheating": { + "description": "Lamp reaches a critical temperature (overheating)", + "data": {"type": "string"}, + "forms": [{ + "href": "coaps://mylamp.example.com/oh" + }] + } + } +} diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..a0c8d1b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import os +import socket + +from tornado.escape import to_unicode + +DEFAULT_TIMEOUT_SECS = 30 +TIMEOUT_CORO_VAR = "WOTPY_TESTS_CORO_TIMEOUT" + + +def run_test_coroutine(coro, timeout=None): + """Synchronously runs the given test coroutine with an optinally defined timeout.""" + + timeout = timeout if timeout else os.getenv(TIMEOUT_CORO_VAR, str(DEFAULT_TIMEOUT_SECS)) + + async def main(): + await asyncio.wait_for( + coro(), timeout=float(timeout)) + + loop = asyncio.get_event_loop_policy().get_event_loop() + loop.run_until_complete(main()) + +def assert_equal_dict(dict_a, dict_b, compare_as_unicode=False): + """Asserts that both dicts are equal.""" + + assert set(dict_a.keys()) == set(dict_b.keys()) + + for key in dict_a: + value_a = dict_a[key] + value_b = dict_b[key] + + if compare_as_unicode and isinstance(value_a, str): + assert to_unicode(value_a) == to_unicode(value_b) + else: + assert value_a == value_b + + +def find_free_port(): + """Returns a free TCP port by attempting to open a socket on an OS-assigned port.""" + + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("", 0)) + return sock.getsockname()[1] + finally: + if sock: + sock.close() diff --git a/tests/wot/__init__.py b/tests/wot/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/wot/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/wot/conftest.py b/tests/wot/conftest.py new file mode 100644 index 0000000..7b86546 --- /dev/null +++ b/tests/wot/conftest.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import uuid + +import pytest +import tornado.web +from faker import Faker +from unittest.mock import MagicMock + +from tests.td_examples import TD_EXAMPLE +from tests.utils import find_free_port +from wotpy.protocols.client import BaseProtocolClient +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.consumed.thing import ConsumedThing +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription +from wotpy.wot.thing import Thing + + +def _build_property_fragment(): + """Builds and returns a random Property init fragment.""" + + return PropertyFragmentDict({ + "description": Faker().sentence(), + "readOnly": False, + "observable": True, + "type": "string" + }) + + +def _build_event_fragment(): + """Builds and returns a random Event init fragment.""" + + return EventFragmentDict({ + "description": Faker().sentence(), + "data": {"type": "string"} + }) + + +def _build_action_fragment(): + """Builds and returns a random Action init fragment.""" + + return ActionFragmentDict({ + "description": Faker().sentence(), + "input": { + "type": "string", + "description": Faker().sentence() + }, + "output": { + "type": "string", + "description": Faker().sentence() + } + }) + + +@pytest.fixture +def property_fragment(): + """Builds and returns a random Property init fragment.""" + + return _build_property_fragment() + + +@pytest.fixture +def action_fragment(): + """Builds and returns a random ActionInit.""" + + return _build_action_fragment() + + +@pytest.fixture +def event_fragment(): + """Builds and returns a random EventInit.""" + + return _build_event_fragment() + + + +@pytest.fixture +def exposed_thing(): + """Builds and returns a random ExposedThing.""" + + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + + return ExposedThing( + servient=Servient(), + thing=thing) + + +@pytest.fixture +def td_example_tornado_app(): + """Builds a Tornado web application with a simple handler + that exposes the example Thing Description document.""" + + # noinspection PyAbstractClass + class TDHandler(tornado.web.RequestHandler): + """Dummy handler to fetch a JSON-serialized TD document.""" + + def get(self): + self.write(TD_EXAMPLE) + + return tornado.web.Application([(r"/", TDHandler)]) + + +class ExposedThingProxyClient(BaseProtocolClient): + """Dummy Protocol Binding client implementation that + basically serves as a proxy for a local ExposedThing object.""" + + def __init__(self, exp_thing): + self._exp_thing = exp_thing + super(ExposedThingProxyClient, self).__init__() + + @property + def protocol(self): + return None + + def is_supported_interaction(self, td, name): + return True + + async def invoke_action(self, td, name, input_value, timeout=None): + result = await self._exp_thing.invoke_action(name, input_value) + return(result) + + async def write_property(self, td, name, value, timeout=None): + await self._exp_thing.write_property(name, value) + + async def read_property(self, td, name, timeout=None): + value = await self._exp_thing.read_property(name) + return(value) + + def on_event(self, td, name): + return self._exp_thing.on_event(name) + + def on_property_change(self, td, name): + return self._exp_thing.on_property_change(name) + + def on_td_change(self, url): + return self._exp_thing.on_td_change() + + +@pytest.fixture +def consumed_exposed_pair(): + """Returns a dict with two keys: + * consumed_thing: A ConsumedThing instance. The Servient instance that contains this + ConsumedThing has been patched to use the ExposedThingProxyClient Protocol Binding client. + * exposed_thing: The ExposedThing behind the previous ConsumedThing (for assertion purposes).""" + + servient = Servient() + + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + exp_thing = ExposedThing(servient=Servient(), thing=thing) + + servient.select_client = MagicMock(return_value=ExposedThingProxyClient(exp_thing)) + + async def lower(parameters): + input_value = parameters.get("input") + await asyncio.sleep(0) + return(str(input_value).lower()) + + exp_thing.add_property(uuid.uuid4().hex, _build_property_fragment()) + exp_thing.add_action(uuid.uuid4().hex, _build_action_fragment(), lower) + exp_thing.add_event(uuid.uuid4().hex, _build_event_fragment()) + + td = ThingDescription.from_thing(exp_thing.thing) + + return { + "consumed_thing": ConsumedThing(servient=servient, td=td), + "exposed_thing": exp_thing + } + + +@pytest.fixture(params=[{"catalogue_enabled": True}]) +def servient(request): + """Returns an empty WoT Servient.""" + + catalogue_port = find_free_port() if request.param.get('catalogue_enabled') else None + + servient = Servient(catalogue_port=catalogue_port) + + async def start(): + await servient.start() + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + yield servient + + async def shutdown(): + await servient.shutdown() + + loop.run_until_complete(shutdown()) diff --git a/tests/wot/discovery/__init__.py b/tests/wot/discovery/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/wot/discovery/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/wot/discovery/dnssd/__init__.py b/tests/wot/discovery/dnssd/__init__.py new file mode 100644 index 0000000..24e33ba --- /dev/null +++ b/tests/wot/discovery/dnssd/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + diff --git a/tests/wot/discovery/dnssd/conftest.py b/tests/wot/discovery/dnssd/conftest.py new file mode 100644 index 0000000..30b745e --- /dev/null +++ b/tests/wot/discovery/dnssd/conftest.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import collections +import logging +import socket + +import pytest +from faker import Faker + +from tests.utils import find_free_port +from wotpy.support import is_dnssd_supported +from wotpy.wot.servient import Servient + +collect_ignore = [] + +if not is_dnssd_supported(): + logging.warning("Skipping DNS-SD tests due to unsupported platform") + collect_ignore.extend(["test_service.py"]) + + +@pytest.fixture +def asyncio_zeroconf(): + """Builds an aiozeroconf service instance and starts browsing for WoT Servient services. + Provides a deque that contains the service state change history.""" + + from aiozeroconf import Zeroconf, ServiceBrowser + from wotpy.wot.discovery.dnssd.service import DNSSDDiscoveryService + + loop = asyncio.get_event_loop_policy().get_event_loop() + + service_history = collections.deque([]) + + def on_change(zc, service_type, name, state_change): + service_history.append((service_type, name, state_change)) + + aio_zc = Zeroconf(loop, address_family=[socket.AF_INET]) + ServiceBrowser(aio_zc, DNSSDDiscoveryService.WOT_SERVICE_TYPE, handlers=[on_change]) + + yield { + "zeroconf": aio_zc, + "service_history": service_history + } + + async def close(): + await aio_zc.close() + + loop.run_until_complete(close()) + + +@pytest.fixture +def dnssd_discovery(): + """Builds an instance of the DNS-SD service.""" + + from wotpy.wot.discovery.dnssd.service import DNSSDDiscoveryService + + dnssd_discovery = DNSSDDiscoveryService() + + yield dnssd_discovery + + async def stop(): + await dnssd_discovery.stop() + + loop = asyncio.get_event_loop_policy().get_event_loop() + loop.run_until_complete(stop()) + + +@pytest.fixture +def dnssd_servient(): + """Builds a Servient with both the TD catalogue and the DNS-SD service enabled.""" + + servient = Servient( + catalogue_port=find_free_port(), + dnssd_enabled=True, + dnssd_instance_name=Faker().pystr()) + + yield servient + + async def shutdown(): + await servient.shutdown() + + loop = asyncio.get_event_loop_policy().get_event_loop() + loop.run_until_complete(shutdown()) diff --git a/tests/wot/discovery/dnssd/test_service.py b/tests/wot/discovery/dnssd/test_service.py new file mode 100644 index 0000000..da0ca54 --- /dev/null +++ b/tests/wot/discovery/dnssd/test_service.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import socket + +import pytest +from aiozeroconf import ServiceStateChange, ServiceInfo +from faker import Faker + +from tests.utils import find_free_port, run_test_coroutine +from wotpy.wot.discovery.dnssd.service import DNSSDDiscoveryService, build_servient_service_info +from wotpy.wot.servient import Servient + + +def _assert_service_added_removed(servient, service_history, instance_name=None): + """Checks the service change history to assert that + the servient service has been added and then removed.""" + + info = build_servient_service_info(servient, instance_name=instance_name) + servient_items = [item for item in service_history if item[1] == info.name] + + assert servient_items[-1][0] == service_history[-2][0] == DNSSDDiscoveryService.WOT_SERVICE_TYPE + assert servient_items[-2][2] == ServiceStateChange.Added + assert servient_items[-1][2] == ServiceStateChange.Removed + + +def _num_service_instance_items(servient, service_history, instance_name=None): + """Returns the number of items in the given service history that match the servient.""" + + info = build_servient_service_info(servient, instance_name=instance_name) + return len([item for item in service_history if item[1] == info.name]) + + +def test_start_stop(): + """The DNS-SD service can be started and stopped.""" + + async def test_coroutine(): + dnssd_discovery = DNSSDDiscoveryService() + + await dnssd_discovery.start() + + assert dnssd_discovery.is_running + + for _ in range(10): + await dnssd_discovery.stop() + + assert not dnssd_discovery.is_running + + for _ in range(10): + await dnssd_discovery.start() + + assert dnssd_discovery.is_running + + await dnssd_discovery.stop() + + assert not dnssd_discovery.is_running + + run_test_coroutine(test_coroutine) + + +def test_register(asyncio_zeroconf, dnssd_discovery): + """WoT Servients may be registered for discovery on the DNS-SD service.""" + + async def test_coroutine(): + service_history = asyncio_zeroconf.pop("service_history") + + port_catalogue = find_free_port() + servient = Servient(catalogue_port=port_catalogue) + + with pytest.raises(ValueError): + await dnssd_discovery.register(servient) + + await dnssd_discovery.start() + + assert not len(service_history) + + await dnssd_discovery.register(servient) + + while _num_service_instance_items(servient, service_history) < 1: + await asyncio.sleep(0.1) + + await dnssd_discovery.stop() + + while _num_service_instance_items(servient, service_history) < 2: + await asyncio.sleep(0.1) + + _assert_service_added_removed(servient, service_history) + + run_test_coroutine(test_coroutine) + + +def test_unregister(asyncio_zeroconf, dnssd_discovery): + """WoT Servients that have been previously registered + on the DNS-SD service can be unregistered.""" + + async def test_coroutine(): + service_history = asyncio_zeroconf.pop("service_history") + + port_catalogue = find_free_port() + servient = Servient(catalogue_port=port_catalogue) + + await dnssd_discovery.start() + await dnssd_discovery.register(servient) + await dnssd_discovery.unregister(servient) + + while _num_service_instance_items(servient, service_history) < 2: + await asyncio.sleep(0.1) + + _assert_service_added_removed(servient, service_history) + + run_test_coroutine(test_coroutine) + + +def test_find(asyncio_zeroconf, dnssd_discovery): + """Remote WoT Servients may be discovered using the DNS-SD service.""" + + async def test_coroutine(): + aio_zc = asyncio_zeroconf.pop("zeroconf") + + ipaddr = Faker().ipv4_private() + port = find_free_port() + service_name = "{}.{}".format( + Faker().pystr(), DNSSDDiscoveryService.WOT_SERVICE_TYPE) + server = "{}.local.".format(Faker().pystr()) + + info = ServiceInfo( + DNSSDDiscoveryService.WOT_SERVICE_TYPE, + service_name, + address=socket.inet_aton(ipaddr), + port=port, + properties={}, + server=server) + + await aio_zc.register_service(info) + + with pytest.raises(ValueError): + await dnssd_discovery.find() + + await dnssd_discovery.start() + + assert (ipaddr, port) in (await dnssd_discovery.find(timeout=3)) + + run_test_coroutine(test_coroutine) + + +def test_register_instance_name(asyncio_zeroconf, dnssd_discovery): + """WoT Servients may be registered with custom service instance names.""" + + async def test_coroutine(): + service_history = asyncio_zeroconf.pop("service_history") + + port_catalogue = find_free_port() + servient = Servient(catalogue_port=port_catalogue) + + instance_name = Faker().sentence() + instance_name = instance_name.strip('.')[:32] + + await dnssd_discovery.start() + await dnssd_discovery.register(servient, instance_name=instance_name) + + while _num_service_instance_items(servient, service_history, instance_name) < 1: + await asyncio.sleep(0.1) + + await dnssd_discovery.stop() + + while _num_service_instance_items(servient, service_history, instance_name) < 2: + await asyncio.sleep(0.1) + + assert len([item[1].startswith(instance_name) + for item in service_history]) == 2 + + with pytest.raises(Exception): + _assert_service_added_removed(servient, service_history) + + _assert_service_added_removed(servient, service_history, instance_name) + + run_test_coroutine(test_coroutine) + + +def test_enable_on_servient(asyncio_zeroconf, dnssd_servient): + """The DNS-SD service may be enabled directly on the + Servient to avoid the need of explicit instantiation.""" + + async def test_coroutine(): + service_history = asyncio_zeroconf.pop("service_history") + instance_name = dnssd_servient.dnssd_instance_name + + await dnssd_servient.start() + + while _num_service_instance_items(dnssd_servient, service_history, instance_name) < 1: + await asyncio.sleep(0.1) + + await dnssd_servient.shutdown() + + while _num_service_instance_items(dnssd_servient, service_history, instance_name) < 2: + await asyncio.sleep(0.1) + + _assert_service_added_removed( + dnssd_servient, service_history, instance_name) + + run_test_coroutine(test_coroutine) diff --git a/tests/wot/test_consumed.py b/tests/wot/test_consumed.py new file mode 100644 index 0000000..c707ea9 --- /dev/null +++ b/tests/wot/test_consumed.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import uuid + +from faker import Faker +from reactivex.scheduler.eventloop import IOLoopScheduler +from tornado import ioloop + +from tests.utils import find_free_port, run_test_coroutine +from wotpy.protocols.http.client import HTTPClient +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.ws.client import WebsocketClient +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription + + +def _test_property_change_events(exposed_thing, subscribe_func): + """Helper function to test client subscriptions to property change events.""" + + async def test_coroutine(): + td = ThingDescription.from_thing(exposed_thing.thing) + prop_name = next(iter(td.properties.keys())) + + loop = asyncio.get_running_loop() + future_conn = loop.create_future() + future_change = loop.create_future() + + prop_value = Faker().sentence() + + def on_next(ev): + if not future_conn.done(): + future_conn.set_result(True) + return + + if ev.data.value == prop_value: + future_change.set_result(True) + + subscription = subscribe_func(prop_name, on_next) + + while not future_conn.done(): + await asyncio.sleep(0) + await exposed_thing.write_property(prop_name, Faker().sentence()) + + await exposed_thing.write_property(prop_name, prop_value) + + await future_change + + assert future_change.result() + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def _test_event_emission_events(exposed_thing, subscribe_func): + """Helper function to test client subscription to event emissions.""" + + async def test_coroutine(): + td = ThingDescription.from_thing(exposed_thing.thing) + event_name = next(iter(td.events.keys())) + + loop = asyncio.get_running_loop() + future_conn = loop.create_future() + future_event = loop.create_future() + + payload = Faker().sentence() + + def on_next(ev): + if not future_conn.done(): + future_conn.set_result(True) + return + + if ev.data == payload: + future_event.set_result(True) + + subscription = subscribe_func(event_name, on_next) + + while not future_conn.done(): + await asyncio.sleep(0) + exposed_thing.emit_event(event_name, Faker().sentence()) + + exposed_thing.emit_event(event_name, payload) + + await future_event + + assert future_event.result() + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def test_thing_template_getters(consumed_exposed_pair): + """ThingTemplate properties can be accessed from the ConsumedThing.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + thing_template = consumed_thing.td.to_thing_fragment() + + assert consumed_thing.id == thing_template.id + assert consumed_thing.title == thing_template.title + assert consumed_thing.description == thing_template.description + + +def test_read_property(consumed_exposed_pair): + """A ConsumedThing is able to read properties.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + prop_name = next(iter(consumed_thing.td.properties.keys())) + + result_exposed = await exposed_thing.read_property(prop_name) + result_consumed = await consumed_thing.read_property(prop_name) + + assert result_consumed == result_exposed + + run_test_coroutine(test_coroutine) + + +def test_write_property(consumed_exposed_pair): + """A ConsumedThing is able to write properties.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + prop_name = next(iter(consumed_thing.td.properties.keys())) + + val_01 = Faker().sentence() + val_02 = Faker().sentence() + + await exposed_thing.write_property(prop_name, val_01) + value = await exposed_thing.read_property(prop_name) + + assert value == val_01 + + await consumed_thing.write_property(prop_name, val_02) + value = await exposed_thing.read_property(prop_name) + + assert value == val_02 + + run_test_coroutine(test_coroutine) + + +def test_invoke_action(consumed_exposed_pair): + """A ConsumedThing is able to invoke actions.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + action_name = next(iter(consumed_thing.td.actions.keys())) + + input_value = Faker().pystr() + result = await consumed_thing.invoke_action(action_name, input_value) + result_expected = await exposed_thing.invoke_action(action_name, input_value) + + assert result == result_expected + + run_test_coroutine(test_coroutine) + + +def test_on_event(consumed_exposed_pair): + """A ConsumedThing is able to observe events.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + def subscribe_func(event_name, on_next): + observable = consumed_thing.on_event(event_name) + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + return observable.subscribe(on_next, scheduler=scheduler) + + _test_event_emission_events(exposed_thing, subscribe_func) + + +def test_on_property_change(consumed_exposed_pair): + """A ConsumedThing is able to observe property updates.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + def subscribe_func(prop_name, on_next): + observable = consumed_thing.on_property_change(prop_name) + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + return observable.subscribe(on_next, scheduler=scheduler) + + _test_property_change_events(exposed_thing, subscribe_func) + + +def test_thing_property_get(consumed_exposed_pair): + """Property values can be retrieved on ConsumedThings using the map-like interface.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + prop_name = next(iter(consumed_thing.td.properties.keys())) + + result_exposed = await exposed_thing.read_property(prop_name) + result_consumed = await consumed_thing.properties[prop_name].read() + + assert result_consumed == result_exposed + + run_test_coroutine(test_coroutine) + + +def test_thing_property_set(consumed_exposed_pair): + """Property values can be updated on ConsumedThings using the map-like interface.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + prop_name = next(iter(consumed_thing.td.properties.keys())) + updated_value = Faker().sentence() + curr_value = await exposed_thing.read_property(prop_name) + + assert consumed_thing.td.properties[prop_name].writable + assert curr_value != updated_value + + await consumed_thing.properties[prop_name].write(updated_value) + result_exposed = await exposed_thing.read_property(prop_name) + + assert result_exposed == updated_value + + run_test_coroutine(test_coroutine) + + +def test_thing_property_subscribe(consumed_exposed_pair): + """Property updates can be observed on ConsumedThings using the map-like interface.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + def subscribe_func(prop_name, on_next): + return consumed_thing.properties[prop_name].subscribe(on_next) + + _test_property_change_events(exposed_thing, subscribe_func) + + +def test_thing_property_getters(consumed_exposed_pair): + """ThingProperty retrieved from ConsumedThing expose the attributes + from the Interaction, InteractionFragment and PropertyFragment interfaces.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + prop_name = next(iter(consumed_thing.td.properties.keys())) + thing_prop_con = consumed_thing.properties[prop_name] + thing_prop_exp = exposed_thing.properties[prop_name] + + assert len(thing_prop_con.forms) == 0 + assert thing_prop_con.title == thing_prop_exp.title + assert thing_prop_con.description == thing_prop_exp.description + assert thing_prop_con.observable == thing_prop_exp.observable + assert thing_prop_con.type == thing_prop_exp.type + + run_test_coroutine(test_coroutine) + + +def test_thing_action_run(consumed_exposed_pair): + """Actions can be invoked on ConsumedThings using the map-like interface.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + action_name = next(iter(consumed_thing.td.actions.keys())) + + input_value = Faker().pystr() + result = await consumed_thing.actions[action_name].invoke(input_value) + result_expected = await exposed_thing.invoke_action(action_name, input_value) + + assert result == result_expected + + run_test_coroutine(test_coroutine) + + +def test_thing_action_getters(consumed_exposed_pair): + """ThingAction retrieved from ConsumedThing expose the attributes + from the Interaction, InteractionFragment and ActionFragment interfaces.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + action_name = next(iter(consumed_thing.td.actions.keys())) + thing_action_con = consumed_thing.actions[action_name] + thing_action_exp = exposed_thing.actions[action_name] + + assert len(thing_action_con.forms) == 0 + assert thing_action_con.title == thing_action_exp.title + assert thing_action_con.description == thing_action_exp.description + assert thing_action_con.input.type == thing_action_exp.input.type + assert thing_action_con.output.type == thing_action_exp.output.type + + run_test_coroutine(test_coroutine) + + +def test_thing_event_subscribe(consumed_exposed_pair): + """Property updates can be observed on ConsumedThings using the map-like interface.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + def subscribe_func(event_name, on_next): + return consumed_thing.events[event_name].subscribe(on_next) + + _test_event_emission_events(exposed_thing, subscribe_func) + + +def test_thing_event_getters(consumed_exposed_pair): + """ThingEvent retrieved from ConsumedThing expose the attributes + from the Interaction, InteractionFragment and EventFragment interfaces.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + exposed_thing = consumed_exposed_pair.pop("exposed_thing") + + async def test_coroutine(): + event_name = next(iter(consumed_thing.td.events.keys())) + thing_event_con = consumed_thing.events[event_name] + thing_event_exp = exposed_thing.events[event_name] + + assert len(thing_event_con.forms) == 0 + assert thing_event_con.title == thing_event_exp.title + assert thing_event_con.description == thing_event_exp.description + assert thing_event_con.data.type == thing_event_exp.data.type + + run_test_coroutine(test_coroutine) + + +def test_thing_interaction_dict_behaviour(consumed_exposed_pair): + """The Interactions dict-like interface of a ConsumedThing behaves like a dict.""" + + consumed_thing = consumed_exposed_pair.pop("consumed_thing") + + prop_name = next((key for key in consumed_thing.properties), None) + + assert prop_name + assert len(consumed_thing.properties) > 0 + assert prop_name in consumed_thing.properties + + +def test_consumed_client_protocols_preference(): + """The Servient selects different protocol clients to consume Things + depending on the protocol choices displayed on the Thing Description.""" + + servient = Servient(catalogue_port=None) + + async def servient_start(): + return (await servient.start()) + + async def servient_shutdown(): + await servient.shutdown() + + http_port = find_free_port() + http_server = HTTPServer(port=http_port) + + servient.add_server(http_server) + + ws_port = find_free_port() + ws_server = WebsocketServer(port=ws_port) + + servient.add_server(ws_server) + + client_server_map = { + HTTPClient: http_server, + WebsocketClient: ws_server + } + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(servient_start()) + + prop_name = uuid.uuid4().hex + + td_produce = ThingDescription({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + prop_name: { + "observable": True, + "type": "string" + } + } + }) + + exposed_thing = wot.produce(td_produce.to_str()) + exposed_thing.expose() + + td_forms_all = ThingDescription.from_thing(exposed_thing.thing) + + client_01 = servient.select_client(td_forms_all, prop_name) + client_01_class = client_01.__class__ + + assert client_01_class in client_server_map.keys() + + loop.run_until_complete(servient_shutdown()) + servient.remove_server(client_server_map[client_01_class].protocol) + loop.run_until_complete(servient_start()) + + td_forms_removed = ThingDescription.from_thing(exposed_thing.thing) + + client_02 = servient.select_client(td_forms_removed, prop_name) + client_02_class = client_02.__class__ + + assert client_02_class != client_01_class + assert client_02_class in client_server_map.keys() + + loop.run_until_complete(servient_shutdown()) diff --git a/tests/wot/test_dictionaries.py b/tests/wot/test_dictionaries.py new file mode 100644 index 0000000..51e7a27 --- /dev/null +++ b/tests/wot/test_dictionaries.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import json + +import pytest +from faker import Faker + +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.dictionaries.link import LinkDict, FormDict +from wotpy.wot.dictionaries.schema import DataSchemaDict +from wotpy.wot.dictionaries.security import SecuritySchemeDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.dictionaries.version import VersioningDict +from wotpy.wot.enums import SecuritySchemeType, DataType + + +def test_link_dict(): + """Link dictionaries can be represented and serialized.""" + + init = { + "href": Faker().url(), + "type": Faker().pystr() + } + + link_dict = LinkDict(init) + + assert link_dict.to_dict().get("href") == init["href"] + assert json.dumps(link_dict.to_dict()) + + with pytest.raises(Exception): + LinkDict({"type": Faker().pystr()}) + + +def test_form_dict(): + """Form dictionaries can be represented and serialized.""" + + init = { + "href": Faker().url(), + "type": Faker().pystr(), + "security": ["nosec_sc"] + } + + form_dict = FormDict(init) + + assert form_dict.content_type + assert form_dict.to_dict().get("href") == init["href"] + assert form_dict.to_dict().get("security")[0] == init["security"][0] + assert json.dumps(form_dict.to_dict()) + + with pytest.raises(Exception): + FormDict({"type": Faker().pystr()}) + + +def test_property_fragment(): + """Property fragment dictionaries can be represented and serialized.""" + + init = { + "description": "Shows the current status of the lamp", + "readOnly": True, + "observable": False, + "type": "string", + "forms": [{ + "href": "coaps://mylamp.example.com/status", + "contentType": "application/json", + "security": ["nosec_sc"] + }] + } + + prop_fragment = PropertyFragmentDict(init) + + assert prop_fragment.read_only == init["readOnly"] + assert prop_fragment.write_only is False + assert prop_fragment.observable == init["observable"] + assert isinstance(prop_fragment.data_schema, DataSchemaDict) + assert prop_fragment.data_schema.type == init["type"] + assert len(prop_fragment.forms) == len(init["forms"]) + assert prop_fragment.forms[0].href == init["forms"][0]["href"] + assert prop_fragment.forms[0].security[0] == init["forms"][0]["security"][0] + assert json.dumps(prop_fragment.to_dict()) + + with pytest.raises(Exception): + PropertyFragmentDict({}) + + +def test_action_fragment(): + """Action fragment dictionaries can be represented and serialized.""" + + init = { + "description": "Turn on or off the lamp", + "forms": [{ + "href": "coaps://mylamp.example.com/toggle", + "contentType": "application/json" + }], + "input": { + "type": "string" + }, + "output": { + "description": "Fake output schema.", + "type": "object", + "properties": { + "title": {"type": "string"}, + "id": {"type": "string"}, + "description": {"type": "string"} + }, + "required": ["id"] + } + } + + action_fragment = ActionFragmentDict(init) + + assert action_fragment.description == init["description"] + assert isinstance(action_fragment.input, DataSchemaDict) + assert isinstance(action_fragment.output, DataSchemaDict) + assert action_fragment.to_dict()["output"]["type"] == init["output"]["type"] + assert json.dumps(action_fragment.to_dict()) + + +def test_event_fragment(): + """Event fragment dictionaries can be represented and serialized.""" + + init = { + "description": "Lamp reaches a critical temperature (overheating)", + "data": {"type": "string"}, + "forms": [{ + "href": "coaps://mylamp.example.com/oh", + "contentType": "application/json" + }], + "uriVariables": { + "p": {"type": "integer", "minimum": 0, "maximum": 16}, + "d": {"type": "integer", "minimum": 0, "maximum": 1} + } + } + + event_fragment = EventFragmentDict(init) + + assert event_fragment.description == init["description"] + assert isinstance(event_fragment.data, DataSchemaDict) + assert isinstance(next(iter(event_fragment.uri_variables.values())), DataSchemaDict) + assert event_fragment.to_dict()["forms"][0]["href"] == init["forms"][0]["href"] + assert json.dumps(event_fragment.to_dict()) + + +THING_INIT = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": "urn:dev:wot:com:example:servient:lamp", + "title": "MyLampThing", + "description": "MyLampThing uses JSON-LD 1.1 serialization", + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "version": {"instance": "1.2.1"}, + "properties": { + "status": { + "description": "Shows the current status of the lamp", + "type": "string", + "forms": [{ + "href": "coaps://mylamp.example.com/status" + }] + } + }, + "actions": { + "toggle": { + "description": "Turn on or off the lamp", + "forms": [{ + "href": "coaps://mylamp.example.com/toggle" + }] + } + }, + "events": { + "overheating": { + "description": "Lamp reaches a critical temperature (overheating)", + "data": {"type": "string"}, + "forms": [{ + "href": "coaps://mylamp.example.com/oh" + }] + } + } +} + + +def test_thing_fragment(): + """Thing fragment dictionaries can be represented and serialized.""" + + thing_fragment = ThingFragment(THING_INIT) + + assert thing_fragment.id == THING_INIT["id"] + assert thing_fragment.title == THING_INIT["title"] + assert thing_fragment.description == THING_INIT["description"] + assert isinstance(next(iter(thing_fragment.properties.values())), PropertyFragmentDict) + assert isinstance(next(iter(thing_fragment.actions.values())), ActionFragmentDict) + assert isinstance(next(iter(thing_fragment.events.values())), EventFragmentDict) + assert json.dumps(thing_fragment.to_dict()) + assert next(iter(thing_fragment.to_dict()["properties"].values()))["type"] + assert thing_fragment.version.instance == THING_INIT["version"]["instance"] + + with pytest.raises(Exception): + ThingFragment({}) + + +def test_thing_fragment_setters(): + """Thing fragment properties can be set.""" + + thing_fragment = ThingFragment(THING_INIT) + + with pytest.raises(AttributeError): + thing_fragment.title = Faker().pystr() + + prop_fragment = PropertyFragmentDict(description=Faker().pystr(), type=DataType.NUMBER) + props_updated = {Faker().pystr(): prop_fragment} + + # noinspection PyPropertyAccess + thing_fragment.properties = props_updated + + assert next(iter(thing_fragment.properties.values())).description == prop_fragment.description + + security_defs_updated = {"psk_sc": SecuritySchemeDict(scheme=SecuritySchemeType.PSK)} + + # noinspection PyPropertyAccess + thing_fragment.security_definitions = security_defs_updated + + assert thing_fragment.security_definitions["psk_sc"].scheme == security_defs_updated["psk_sc"].scheme + + version_updated = VersioningDict(instance=Faker().pystr()) + + # noinspection PyPropertyAccess + thing_fragment.version = version_updated + + assert thing_fragment.version.instance == version_updated.instance diff --git a/tests/wot/test_exposed.py b/tests/wot/test_exposed.py new file mode 100644 index 0000000..b1abd98 --- /dev/null +++ b/tests/wot/test_exposed.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import time +import uuid +# noinspection PyCompatibility +from concurrent.futures import ThreadPoolExecutor + +import pytest +from faker import Faker +from slugify import slugify + +from tests.utils import run_test_coroutine +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.enums import TDChangeMethod, TDChangeType, DataType +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.servient import Servient +from wotpy.wot.thing import Thing + + +def _test_td_change_events(exposed_thing, property_fragment, event_fragment, action_fragment, subscribe_func): + """Helper function to test subscriptions to TD changes.""" + + async def test_coroutine(): + prop_name = Faker().pystr() + event_name = Faker().pystr() + action_name = Faker().pystr() + + loop = asyncio.get_running_loop() + complete_futures = { + (TDChangeType.PROPERTY, TDChangeMethod.ADD): loop.create_future(), + (TDChangeType.PROPERTY, TDChangeMethod.REMOVE): loop.create_future(), + (TDChangeType.EVENT, TDChangeMethod.ADD): loop.create_future(), + (TDChangeType.EVENT, TDChangeMethod.REMOVE): loop.create_future(), + (TDChangeType.ACTION, TDChangeMethod.ADD): loop.create_future(), + (TDChangeType.ACTION, TDChangeMethod.REMOVE): loop.create_future() + } + + def on_next(ev): + change_type = ev.data.td_change_type + change_method = ev.data.method + interaction_name = ev.data.name + future_key = (change_type, change_method) + complete_futures[future_key].set_result(interaction_name) + + subscription = subscribe_func(on_next) + + await asyncio.sleep(0) + + exposed_thing.add_event(event_name, event_fragment) + + assert complete_futures[(TDChangeType.EVENT, TDChangeMethod.ADD)].result() == event_name + assert not complete_futures[(TDChangeType.EVENT, TDChangeMethod.REMOVE)].done() + + exposed_thing.remove_event(name=event_name) + exposed_thing.add_property(prop_name, property_fragment) + + assert complete_futures[(TDChangeType.EVENT, TDChangeMethod.REMOVE)].result() == event_name + assert complete_futures[(TDChangeType.PROPERTY, TDChangeMethod.ADD)].result() == prop_name + assert not complete_futures[(TDChangeType.PROPERTY, TDChangeMethod.REMOVE)].done() + + exposed_thing.remove_property(name=prop_name) + exposed_thing.add_action(action_name, action_fragment) + exposed_thing.remove_action(name=action_name) + + assert complete_futures[(TDChangeType.PROPERTY, TDChangeMethod.REMOVE)].result() == prop_name + assert complete_futures[(TDChangeType.ACTION, TDChangeMethod.ADD)].result() == action_name + assert complete_futures[(TDChangeType.ACTION, TDChangeMethod.REMOVE)].result() == action_name + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def test_thing_template_getters(exposed_thing): + """ThingTemplate properties can be accessed from the ExposedThing.""" + + thing_template = exposed_thing.thing.thing_fragment + + assert exposed_thing.id == thing_template.id + assert exposed_thing.title == thing_template.title + assert exposed_thing.description == thing_template.description + + +def test_read_property(exposed_thing, property_fragment): + """Properties may be retrieved on ExposedThings.""" + + async def test_coroutine(): + prop_name = Faker().pystr() + prop_init_value = Faker().sentence() + exposed_thing.add_property(prop_name, property_fragment, value=prop_init_value) + value = await exposed_thing.read_property(prop_name) + assert value == prop_init_value + + run_test_coroutine(test_coroutine) + + +def test_write_property(exposed_thing, property_fragment): + """Properties may be updated on ExposedThings.""" + + assert property_fragment.writable + + async def test_coroutine(): + updated_val = Faker().pystr() + prop_name = Faker().pystr() + + exposed_thing.add_property(prop_name, property_fragment) + + await exposed_thing.write_property(prop_name, updated_val) + + value = await exposed_thing.read_property(prop_name) + + assert value == updated_val + + run_test_coroutine(test_coroutine) + + +def test_invoke_action(exposed_thing, action_fragment): + """Actions can be invoked on ExposedThings.""" + + thread_executor = ThreadPoolExecutor(max_workers=1) + + def upper_thread(parameters): + input_value = parameters.get("input") + return asyncio.wrap_future( + thread_executor.submit(lambda x: time.sleep(0.1) or str(x).upper(), input_value)) + + def upper(parameters): + loop = asyncio.get_running_loop() + input_value = parameters.get("input") + return loop.run_in_executor(None, lambda x: time.sleep(0.1) or str(x).upper(), input_value) + + async def lower(parameters): + input_value = parameters.get("input") + await asyncio.sleep(0) + return str(input_value).lower() + + def title(parameters): + input_value = parameters.get("input") + loop = asyncio.get_running_loop() + future = loop.create_future() + future.set_result(input_value.title()) + return future + + handlers_map = { + upper_thread: lambda x: x.upper(), + upper: lambda x: x.upper(), + lower: lambda x: x.lower(), + title: lambda x: x.title() + } + + async def test_coroutine(): + action_name = Faker().pystr() + exposed_thing.add_action(action_name, action_fragment) + + for handler, assert_func in handlers_map.items(): + exposed_thing.set_action_handler(action_name, handler) + action_arg = Faker().sentence(10) + result = await exposed_thing.invoke_action(action_name, action_arg) + assert result == assert_func(action_arg) + + run_test_coroutine(test_coroutine) + + +def test_invoke_action_undefined_handler(exposed_thing, action_fragment): + """Actions with undefined handlers return an error.""" + + async def test_coroutine(): + action_name = Faker().pystr() + exposed_thing.add_action(action_name, action_fragment) + + with pytest.raises(NotImplementedError): + await exposed_thing.invoke_action(action_name) + + async def dummy_func(parameters): + assert parameters.get("input") is None + return True + + exposed_thing.set_action_handler(action_name, dummy_func) + + result = await exposed_thing.invoke_action(action_name) + + assert result + + run_test_coroutine(test_coroutine) + + +def test_on_property_change(exposed_thing, property_fragment): + """Property changes can be observed.""" + + assert property_fragment.observable + + async def test_coroutine(): + prop_name = Faker().pystr() + assert property_fragment.observable + exposed_thing.add_property(prop_name, property_fragment) + + observable_prop = exposed_thing.on_property_change(prop_name) + + property_values = Faker().pylist(5, True, [str]) + + emitted_values = [] + + def on_next_property_event(ev): + emitted_values.append(ev.data.value) + + subscription = observable_prop.subscribe(on_next_property_event) + + for val in property_values: + await exposed_thing.write_property(prop_name, val) + + assert emitted_values == property_values + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def test_on_property_change_non_observable(exposed_thing): + """Observe requests to non-observable properties are rejected.""" + + async def test_coroutine(): + prop_name = Faker().pystr() + prop_init_non_observable = PropertyFragmentDict({ + "type": "string", + "observable": False + }) + exposed_thing.add_property(prop_name, prop_init_non_observable) + + observable_prop = exposed_thing.on_property_change(prop_name) + + loop = asyncio.get_running_loop() + future_next = loop.create_future() + future_error = loop.create_future() + + def on_next(item): + future_next.set_result(item) + + def on_error(err): + future_error.set_exception(err) + + subscription = observable_prop.subscribe(on_next=on_next, on_error=on_error) + + await exposed_thing.write_property(prop_name, Faker().pystr()) + + with pytest.raises(Exception): + future_error.result() + + assert not future_next.done() + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def test_on_event(exposed_thing, event_fragment): + """Events defined in the Thing Description can be observed.""" + + event_name = Faker().pystr() + exposed_thing.add_event(event_name, event_fragment) + + observable_event = exposed_thing.on_event(event_name) + + event_payloads = [Faker().pystr() for _ in range(5)] + + emitted_payloads = [] + + def on_next_event(ev): + emitted_payloads.append(ev.data) + + subscription = observable_event.subscribe(on_next_event) + + for val in event_payloads: + exposed_thing.emit_event(event_name, val) + + assert emitted_payloads == event_payloads + + subscription.dispose() + + +def test_on_td_change(exposed_thing, property_fragment, event_fragment, action_fragment): + """Thing Description changes can be observed.""" + + def subscribe_func(*args, **kwargs): + return exposed_thing.on_td_change().subscribe(*args, **kwargs) + + _test_td_change_events(exposed_thing, property_fragment, event_fragment, action_fragment, subscribe_func) + + +def test_thing_property_get(exposed_thing, property_fragment): + """Property values can be retrieved on ExposedThings using the map-like interface.""" + + async def test_coroutine(): + prop_name = Faker().pystr() + prop_init_value = Faker().sentence() + exposed_thing.add_property(prop_name, property_fragment, value=prop_init_value) + value = await exposed_thing.properties[prop_name].read() + assert value == prop_init_value + + run_test_coroutine(test_coroutine) + + +def test_thing_property_set(exposed_thing, property_fragment): + """Property values can be updated on ExposedThings using the map-like interface.""" + + assert property_fragment.writable + + async def test_coroutine(): + updated_val = Faker().pystr() + prop_name = Faker().pystr() + assert property_fragment.writable + exposed_thing.add_property(prop_name, property_fragment) + await exposed_thing.properties[prop_name].write(updated_val) + value = await exposed_thing.properties[prop_name].read() + assert value == updated_val + + run_test_coroutine(test_coroutine) + + +def test_thing_property_subscribe(exposed_thing, property_fragment): + """Property updates can be observed on ExposedThings using the map-like interface.""" + + assert property_fragment.observable + + async def test_coroutine(): + prop_name = Faker().pystr() + exposed_thing.add_property(prop_name, property_fragment) + + values = [Faker().sentence() for _ in range(10)] + loop = asyncio.get_running_loop() + values_futures = {key: loop.create_future() for key in values} + + def on_next(ev): + value = ev.data.value + if value in values_futures and not values_futures[value].done(): + values_futures[value].set_result(True) + + subscription = exposed_thing.properties[prop_name].subscribe(on_next) + + await asyncio.sleep(0) + + for val in values: + await exposed_thing.properties[prop_name].write(val) + + await asyncio.gather(*values_futures.values()) + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def test_thing_property_getters(exposed_thing, property_fragment): + """ThingProperty retrieved from ExposedThing expose the attributes + from the Interaction, InteractionFragment and PropertyFragment interfaces.""" + + async def test_coroutine(): + prop_name = Faker().pystr() + prop_init_value = Faker().sentence() + exposed_thing.add_property(prop_name, property_fragment, value=prop_init_value) + thing_property = exposed_thing.properties[prop_name] + + assert len(thing_property.forms) == 0 + assert thing_property.title == property_fragment.title + assert thing_property.description == property_fragment.description + assert thing_property.observable == property_fragment.observable + assert thing_property.type == property_fragment.type + + run_test_coroutine(test_coroutine) + + +def test_thing_action_run(exposed_thing): + """Actions can be invoked on ExposedThings using the map-like interface.""" + + async def lower(parameters): + input_value = parameters.get("input") + return str(input_value).lower() + + async def test_coroutine(): + action_name = Faker().pystr() + action_fragment = create_action_fragment(action_name) + exposed_thing.add_action(action_name, action_fragment, lower) + input_value = Faker().pystr() + + result = await exposed_thing.actions[action_name].invoke(input_value) + result_expected = await exposed_thing.invoke_action(action_name, input_value) + + assert result == result_expected + + run_test_coroutine(test_coroutine) + + +def test_thing_action_run(exposed_thing, action_fragment): + """ThingAction retrieved from ExposedThing expose the attributes + from the Interaction, InteractionFragment and ActionFragment interfaces.""" + + async def test_coroutine(): + action_name = Faker().pystr() + exposed_thing.add_action(action_name, action_fragment) + thing_action = exposed_thing.actions[action_name] + + assert len(thing_action.forms) == 0 + assert thing_action.title == action_fragment.title + assert thing_action.description == action_fragment.description + assert thing_action.input.type == action_fragment.input.type + assert thing_action.output.type == action_fragment.output.type + + run_test_coroutine(test_coroutine) + + +def test_thing_event_subscribe(exposed_thing, event_fragment): + """Property updates can be observed on ExposedThings using the map-like interface.""" + + async def test_coroutine(): + event_name = Faker().pystr() + exposed_thing.add_event(event_name, event_fragment) + + values = [Faker().sentence() for _ in range(10)] + loop = asyncio.get_running_loop() + values_futures = {key: loop.create_future() for key in values} + + def on_next(ev): + value = ev.data + if value in values_futures and not values_futures[value].done(): + values_futures[value].set_result(True) + + subscription = exposed_thing.events[event_name].subscribe(on_next) + + await asyncio.sleep(0) + + for val in values: + exposed_thing.events[event_name].emit(val) + + for future in values_futures.values(): + await future + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +def test_thing_event_getters(exposed_thing, event_fragment): + """ThingEvent retrieved from ExposedThing expose the attributes + from the Interaction, InteractionFragment and EventFragment interfaces.""" + + async def test_coroutine(): + event_name = Faker().pystr() + exposed_thing.add_event(event_name, event_fragment) + thing_event = exposed_thing.events[event_name] + + assert len(thing_event.forms) == 0 + assert thing_event.title == event_fragment.title + assert thing_event.description == event_fragment.description + assert thing_event.data.type == event_fragment.data.type + + run_test_coroutine(test_coroutine) + + +def test_set_property_read_handler(exposed_thing, property_fragment): + """Read handlers can be defined for ExposedThing property interactions.""" + + const_prop_value = Faker().sentence() + + async def read_handler(): + return const_prop_value + + async def test_coroutine(): + prop_name = Faker().pystr() + exposed_thing.add_property(prop_name, property_fragment) + exposed_thing.set_property_read_handler(prop_name, read_handler) + value = await exposed_thing.properties[prop_name].read() + assert value == const_prop_value + + run_test_coroutine(test_coroutine) + + +def test_set_property_write_handler(exposed_thing, property_fragment): + """Write handlers can be defined for ExposedThing property interactions.""" + + prop_history = [] + + async def write_handler(value): + await asyncio.sleep(0) + prop_history.append(value) + + async def test_coroutine(): + prop_name = Faker().pystr() + exposed_thing.add_property(prop_name, property_fragment) + exposed_thing.set_property_write_handler(prop_name, write_handler) + prop_value = Faker().sentence() + assert not len(prop_history) + await exposed_thing.properties[prop_name].write(prop_value) + assert prop_value in prop_history + + run_test_coroutine(test_coroutine) + + +def test_subscribe(exposed_thing, property_fragment, event_fragment, action_fragment): + """Subscribing to the ExposedThing provides Thing Description update events.""" + + def subscribe_func(*args, **kwargs): + return exposed_thing.subscribe(*args, **kwargs) + + _test_td_change_events(exposed_thing, property_fragment, event_fragment, action_fragment, subscribe_func) + + +def test_thing_interaction_dict_behaviour(exposed_thing, property_fragment): + """The Interactions dict-like interface of an ExposedThing behaves like a dict.""" + + prop_name = Faker().pystr() + exposed_thing.add_property(prop_name, property_fragment) + + assert len(exposed_thing.properties) == 1 + assert prop_name in exposed_thing.properties + assert next(key for key in exposed_thing.properties if key == prop_name) + + +def test_thing_fragment_getters_setters(): + """ThingFragment attributes can be get and set from the ExposedThing.""" + + prop_name = uuid.uuid4().hex + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": Faker().pystr(), + "description": Faker().pystr(), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + prop_name: { + "description": Faker().pystr(), + "type": DataType.STRING + } + } + }) + + thing = Thing(thing_fragment=thing_fragment) + exp_thing = ExposedThing(servient=Servient(), thing=thing) + + assert exp_thing.title == thing_fragment.title + assert exp_thing.description == thing_fragment.description + assert list(exp_thing.properties) == list(thing_fragment.properties.keys()) + + id_original = thing_fragment.title + id_updated = uuid.uuid4().urn + + description_original = thing_fragment.description + description_updated = Faker().pystr() + + exp_thing.id = id_updated + exp_thing.description = description_updated + + assert exp_thing.id == id_updated + assert exp_thing.id != id_original + assert exp_thing.description == description_updated + assert exp_thing.description != description_original + + with pytest.raises(AttributeError): + # noinspection PyPropertyAccess + exp_thing.title = Faker().pystr() + + with pytest.raises(AttributeError): + # noinspection PyPropertyAccess + exp_thing.properties = Faker().pylist() + + with pytest.raises(AttributeError): + # noinspection PyPropertyAccess + exp_thing.actions = Faker().pylist() + + with pytest.raises(AttributeError): + # noinspection PyPropertyAccess + exp_thing.events = Faker().pylist() + + +def _test_equivalent_interaction_names(base_name, transform_name): + """Helper function to test that interaction names + are equivalent given a certain transformation function.""" + + prop_name = uuid.uuid4().hex + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": Faker().pystr(), + "description": Faker().pystr(), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + prop_name: { + "description": Faker().pystr(), + "type": DataType.STRING + } + } + }) + + thing = Thing(thing_fragment=thing_fragment) + exp_thing = ExposedThing(servient=Servient(), thing=thing) + + prop_name = "property" + base_name + prop_name_transform = transform_name(prop_name) + + prop_default_value = Faker().pybool() + property_fragment = PropertyFragmentDict({ + "type": DataType.BOOLEAN + }) + exp_thing.add_property(prop_name, property_fragment, value=prop_default_value) + + with pytest.raises(ValueError): + property_fragment = PropertyFragmentDict({ + "type": DataType.BOOLEAN + }) + exp_thing.add_property(prop_name_transform, property_fragment) + + async def assert_prop_read(): + assert (await exp_thing.properties[prop_name].read()) is prop_default_value + assert (await exp_thing.properties[prop_name_transform].read()) is prop_default_value + + loop = asyncio.get_event_loop_policy().get_event_loop() + loop.run_until_complete(assert_prop_read()) + + action_name = "action" + base_name + action_name_transform = transform_name(action_name) + + exp_thing.add_action(action_name, {}) + + with pytest.raises(ValueError): + exp_thing.add_action(action_name_transform, {}) + + assert exp_thing.actions[action_name] + assert exp_thing.actions[action_name_transform] + + event_name = "event" + base_name + event_name_transform = transform_name(event_name) + + exp_thing.add_event(event_name, {}) + + with pytest.raises(ValueError): + exp_thing.add_event(event_name_transform, {}) + + assert exp_thing.events[event_name] + assert exp_thing.events[event_name_transform] + + +def test_interaction_name_case_insensitive(): + """ExposedThing interaction names are equivalent in a case-insensitive fashion.""" + + _test_equivalent_interaction_names("camelCaseStr", lambda name: name.lower()) + _test_equivalent_interaction_names("camelCaseStr", lambda name: name.upper()) + _test_equivalent_interaction_names("camelCaseStr", lambda name: name.title()) + + +def test_interaction_name_url_safe(): + """ExposedThing interaction names are equivalent in a URL-safe fashion.""" + + _test_equivalent_interaction_names("url_UnSafE-Str", lambda name: slugify(name)) diff --git a/tests/wot/test_servient.py b/tests/wot/test_servient.py new file mode 100644 index 0000000..04d9fc2 --- /dev/null +++ b/tests/wot/test_servient.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import json +import random +import uuid + +import pytest +import tornado.httpclient +import tornado.websocket +from faker import Faker + +from tests.utils import find_free_port, run_test_coroutine +from wotpy.protocols.enums import Protocols +from wotpy.protocols.ws.client import WebsocketClient +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.protocols.http.server import HTTPServer +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.consumed.thing import ConsumedThing +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription +from wotpy.wot.wot import WoT + +TD_DICT_01 = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": Faker().sentence(), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + "status": { + "description": "Shows the current status of the lamp", + "type": "string", + "forms": [{ + "href": "coaps://mylamp.example.com:5683/status" + }] + } + } +} + +TD_DICT_02 = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": Faker().sentence(), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", +} + + +async def fetch_catalogue(servient, expanded=False): + """Returns the TD catalogue exposed by the given Servient.""" + + http_client = tornado.httpclient.AsyncHTTPClient() + expanded = "?expanded=true" if expanded else "" + catalogue_url = "http://localhost:{}/{}".format(servient.catalogue_port, expanded) + catalogue_url_res = await http_client.fetch(catalogue_url) + response = json.loads(catalogue_url_res.body) + + return response + + +async def fetch_catalogue_td(servient, thing_id): + """Returns the TD of the given Thing recovered from the Servient TD catalogue.""" + + urls_map = await fetch_catalogue(servient) + thing_path = urls_map[thing_id].lstrip("/") + thing_url = "http://localhost:{}/{}".format(servient.catalogue_port, thing_path) + http_client = tornado.httpclient.AsyncHTTPClient() + thing_url_res = await http_client.fetch(thing_url) + response = json.loads(thing_url_res.body) + + return response + + +def test_servient_td_catalogue(servient): + """The servient provides a Thing Description catalogue HTTP endpoint.""" + + async def test_coroutine(): + wot = WoT(servient=servient) + + td_01_str = json.dumps(TD_DICT_01) + td_02_str = json.dumps(TD_DICT_02) + + exposed_thing_01 = wot.produce(td_01_str) + exposed_thing_02 = wot.produce(td_02_str) + + exposed_thing_01.expose() + exposed_thing_02.expose() + + catalogue = await fetch_catalogue(servient) + + assert len(catalogue) == 2 + assert exposed_thing_01.thing.url_name in catalogue.get(TD_DICT_01["title"]) + assert exposed_thing_02.thing.url_name in catalogue.get(TD_DICT_02["title"]) + + td_01_catalogue = await fetch_catalogue_td(servient, TD_DICT_01["title"]) + + assert td_01_catalogue["id"] == TD_DICT_01["id"] + assert td_01_catalogue["title"] == TD_DICT_01["title"] + + catalogue_expanded = await fetch_catalogue(servient, expanded=True) + + num_props = len(TD_DICT_01.get("properties", {}).keys()) + + assert len(catalogue_expanded) == 2 + assert TD_DICT_01["title"] in catalogue_expanded + assert TD_DICT_02["title"] in catalogue_expanded + assert len(catalogue_expanded[TD_DICT_01["title"]]["properties"]) == num_props + + run_test_coroutine(test_coroutine) + + +def test_servient_start_stop(): + """The servient and contained ExposedThings can be started and stopped.""" + + fake = Faker() + + ws_port = find_free_port() + ws_server = WebsocketServer(port=ws_port) + + servient = Servient() + servient.disable_td_catalogue() + servient.add_server(ws_server) + + async def start(): + return (await servient.start()) + + loop = asyncio.get_event_loop_policy().get_event_loop() + wot = loop.run_until_complete(start()) + + thing_id = uuid.uuid4().urn + title = uuid.uuid4().hex + name_prop_string = fake.user_name() + name_prop_boolean = fake.user_name() + + td_doc = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": thing_id, + "title": title, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "properties": { + name_prop_string: { + "observable": True, + "type": "string" + }, + name_prop_boolean: { + "observable": True, + "type": "boolean" + } + } + } + + td_str = json.dumps(td_doc) + + exposed_thing = wot.produce(td_str) + exposed_thing.expose() + + value_boolean = fake.pybool() + value_string = fake.pystr() + servient.refresh_forms() + + async def get_property(prop_name): + """Gets the given property using the WS Link contained in the thing description.""" + + td = ThingDescription.from_thing(exposed_thing.thing) + consumed_thing = ConsumedThing(servient, td=td) + + value = await consumed_thing.read_property(prop_name) + + return value + + async def assert_thing_active(): + """Asserts that the retrieved property values are as expected.""" + + retrieved_boolean = await get_property(name_prop_boolean) + retrieved_string = await get_property(name_prop_string) + + assert retrieved_boolean == value_boolean + assert retrieved_string == value_string + + async def test_coroutine(): + await exposed_thing.write_property(name=name_prop_boolean, value=value_boolean) + await exposed_thing.write_property(name=name_prop_string, value=value_string) + + await assert_thing_active() + + exposed_thing.destroy() + + with pytest.raises(Exception): + await assert_thing_active() + + with pytest.raises(Exception): + exposed_thing.expose() + + await servient.shutdown() + + run_test_coroutine(test_coroutine) + + +@pytest.mark.parametrize("servient", [{"catalogue_enabled": False}], indirect=True) +def test_duplicated_thing_names(servient): + """A Servient rejects Things with duplicated Titles.""" + + description_01 = { + "@context": [WOT_TD_CONTEXT_URL_V1_1], + "id": uuid.uuid4().urn, + "title": Faker().sentence(), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + } + + description_02 = { + "@context": [WOT_TD_CONTEXT_URL_V1_1], + "id": uuid.uuid4().urn, + "title": Faker().sentence(), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + } + + description_03 = { + "@context": [WOT_TD_CONTEXT_URL_V1_1], + "id": uuid.uuid4().urn, + "title": description_01.get("title"), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + } + + description_01_str = json.dumps(description_01) + description_02_str = json.dumps(description_02) + description_03_str = json.dumps(description_03) + + wot = WoT(servient=servient) + + wot.produce(description_01_str) + wot.produce(description_02_str) + + with pytest.raises(ValueError): + wot.produce(description_03_str) + + +def test_catalogue_disabled_things(servient): + """ExposedThings that have been disabled do not appear on the Servient TD catalogue.""" + + async def test_coroutine(): + wot = WoT(servient=servient) + + td_01_str = json.dumps(TD_DICT_01) + td_02_str = json.dumps(TD_DICT_02) + + wot.produce(td_01_str).expose() + wot.produce(td_02_str) + + catalogue = await fetch_catalogue(servient) + + assert len(catalogue) == 1 + assert TD_DICT_01["title"] in catalogue + + run_test_coroutine(test_coroutine) + + +def test_clients_subset(): + """Although all clients are enabled by default, the user may only enable a subset.""" + + ws_client = WebsocketClient() + servient_01 = Servient() + servient_02 = Servient(clients=[ws_client]) + td = ThingDescription(TD_DICT_01) + prop_name = next(iter(TD_DICT_01["properties"].keys())) + + assert servient_01.select_client(td, prop_name) is not ws_client + assert servient_02.select_client(td, prop_name) is ws_client + + +def test_clients_config(): + """Custom configuration arguments can be passed to the Servient default protocol clients.""" + + connect_timeout = random.random() + servient = Servient(clients_config={Protocols.HTTP: {"connect_timeout": connect_timeout}}) + + assert servient.clients[Protocols.HTTP].connect_timeout == connect_timeout diff --git a/tests/wot/test_td.py b/tests/wot/test_td.py new file mode 100644 index 0000000..1e4d3d9 --- /dev/null +++ b/tests/wot/test_td.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import copy +import uuid + +import pytest +from faker import Faker + +from tests.td_examples import TD_EXAMPLE +from wotpy.protocols.enums import Protocols +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.td import ThingDescription +from wotpy.wot.form import Form +from wotpy.wot.interaction import Action, Property, Event +from wotpy.wot.thing import Thing +from wotpy.wot.validation import InvalidDescription + + +def test_validate(): + """Example TD from the W3C Thing Description page validates correctly.""" + + ThingDescription.validate(doc=TD_EXAMPLE) + + +def test_validate_err(): + """An erroneous Thing Description raises error on validation.""" + + update_funcs = [ + lambda x: x.update({"properties": [1, 2, 3]}) or x, + lambda x: x.update({"actions": "hello-interactions"}) or x, + lambda x: x.update({"events": {"overheating": {"forms": 0.5}}}) or x + ] + + for update_func in update_funcs: + td_err = update_func(copy.deepcopy(TD_EXAMPLE)) + + with pytest.raises(InvalidDescription): + ThingDescription.validate(doc=td_err) + + +def test_from_dict(): + """ThingDescription objects can be built from TD documents in dict format.""" + + td = ThingDescription(TD_EXAMPLE) + + assert td.id == TD_EXAMPLE.get("id") + assert td.title == TD_EXAMPLE.get("title") + assert td.description == TD_EXAMPLE.get("description") + + +def test_from_thing(): + """ThingDescription objects can be built from Thing objects.""" + + fake = Faker() + + thing_id = uuid.uuid4().urn + action_id = uuid.uuid4().hex + prop_id = uuid.uuid4().hex + event_id = uuid.uuid4().hex + action_form_href = fake.url() + prop_form_href = fake.url() + event_form_href = fake.url() + + thing_fragment = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + thing = Thing(thing_fragment=thing_fragment) + + action = Action(thing=thing, name=action_id) + action_form = Form(interaction=action, protocol=Protocols.HTTP, href=action_form_href) + action.add_form(action_form) + thing.add_interaction(action) + + prop = Property(thing=thing, name=prop_id, type="string") + prop_form = Form(interaction=prop, protocol=Protocols.HTTP, href=prop_form_href) + prop.add_form(prop_form) + thing.add_interaction(prop) + + event = Event(thing=thing, name=event_id) + thing.add_interaction(event) + + json_td = ThingDescription.from_thing(thing) + td_dict = json_td.to_dict() + + assert td_dict["id"] == thing.id + assert td_dict["title"] == thing.title + assert len(td_dict["properties"]) == 1 + assert len(td_dict["actions"]) == 1 + assert len(td_dict["events"]) == 1 + assert len(td_dict["actions"][action_id]["forms"]) == 1 + assert len(td_dict["properties"][prop_id]["forms"]) == 1 + assert td_dict["actions"][action_id]["forms"][0]["href"] == action_form_href + assert td_dict["properties"][prop_id]["forms"][0]["href"] == prop_form_href + + +def test_build_thing(): + """Thing objects can be built from ThingDescription objects.""" + + json_td = ThingDescription(TD_EXAMPLE) + thing = json_td.build_thing() + td_dict = json_td.to_dict() + + def assert_same_keys(dict_a, dict_b): + assert sorted(list(dict_a.keys())) == sorted(list(dict_b.keys())) + + assert thing.id == td_dict.get("id") + assert thing.title == td_dict.get("title") + assert thing.description == td_dict.get("description") + assert_same_keys(thing.properties, td_dict.get("properties", {})) + assert_same_keys(thing.actions, td_dict.get("actions", {})) + assert_same_keys(thing.events, td_dict.get("events", {})) diff --git a/tests/wot/test_thing.py b/tests/wot/test_thing.py new file mode 100644 index 0000000..35674bc --- /dev/null +++ b/tests/wot/test_thing.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + + +import string +import uuid + +# noinspection PyPackageRequirements +import pytest +# noinspection PyPackageRequirements +# noinspection PyPackageRequirements +from slugify import slugify + +from wotpy.protocols.enums import Protocols +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.td import ThingDescription +from wotpy.wot.form import Form +from wotpy.wot.interaction import Action +from wotpy.wot.thing import Thing + +TD_DICT = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": uuid.uuid4().hex, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" +} + +def test_empty_thing_valid(): + """An empty Thing initialized by default has a valid JSON-LD serialization.""" + + thing_fragment = ThingFragment(TD_DICT) + thing = Thing(thing_fragment=thing_fragment) + json_td = ThingDescription.from_thing(thing) + ThingDescription.validate(json_td.to_dict()) + + +def test_interaction_invalid_name(): + """Invalid names for Interaction objects are rejected.""" + + names_valid = [ + "safename", + "safename02", + "SafeName_03", + "Safe_Name-04" + ] + + names_invalid = [ + "!unsafename", + "unsafe_name_ñ", + "unsafe name", + "?" + ] + + thing_fragment = ThingFragment(TD_DICT) + thing = Thing(thing_fragment=thing_fragment) + + for name in names_valid: + Action(thing=thing, name=name) + + for name in names_invalid: + with pytest.raises(ValueError): + Action(thing=thing, name=name) + + +def test_find_interaction(): + """Interactions may be retrieved by name on a Thing.""" + + thing_fragment = ThingFragment(TD_DICT) + thing = Thing(thing_fragment=thing_fragment) + + interaction_01 = Action(thing=thing, name="my_interaction") + interaction_02 = Action(thing=thing, name="AnotherInteraction") + + thing.add_interaction(interaction_01) + thing.add_interaction(interaction_02) + + assert thing.find_interaction(interaction_01.name) is interaction_01 + assert thing.find_interaction(interaction_02.name) is interaction_02 + assert thing.find_interaction(slugify(interaction_01.name)) is interaction_01 + assert thing.find_interaction(slugify(interaction_02.name)) is interaction_02 + + +def test_remove_interaction(): + """Interactions may be removed from a Thing by name.""" + + thing_fragment = ThingFragment(TD_DICT) + thing = Thing(thing_fragment=thing_fragment) + + interaction_01 = Action(thing=thing, name="my_interaction") + interaction_02 = Action(thing=thing, name="AnotherInteraction") + interaction_03 = Action(thing=thing, name="YetAnother_interaction") + + thing.add_interaction(interaction_01) + thing.add_interaction(interaction_02) + thing.add_interaction(interaction_03) + + assert thing.find_interaction(interaction_01.name) is not None + assert thing.find_interaction(interaction_02.name) is not None + assert thing.find_interaction(interaction_03.name) is not None + + thing.remove_interaction(interaction_01.name) + thing.remove_interaction(slugify(interaction_03.name)) + + assert thing.find_interaction(interaction_01.name) is None + assert thing.find_interaction(interaction_02.name) is not None + assert thing.find_interaction(interaction_03.name) is None + + +def test_duplicated_interactions(): + """Duplicated Interactions are rejected on a Thing.""" + + thing_fragment = ThingFragment(TD_DICT) + thing = Thing(thing_fragment=thing_fragment) + + interaction_01 = Action(thing=thing, name="my_interaction") + interaction_02 = Action(thing=thing, name="AnotherInteraction") + interaction_03 = Action(thing=thing, name="my_interaction") + + thing.add_interaction(interaction_01) + thing.add_interaction(interaction_02) + + with pytest.raises(ValueError): + thing.add_interaction(interaction_03) + + +def test_duplicated_forms(): + """Duplicated Forms are rejected on an Interaction.""" + + thing_fragment = ThingFragment(TD_DICT) + thing = Thing(thing_fragment=thing_fragment) + interaction = Action(thing=thing, name="my_interaction") + thing.add_interaction(interaction) + + href_01 = "/href-01" + href_02 = "/href-02" + + mtype_01 = "application/json" + mtype_02 = "text/html" + + form_01 = Form(interaction=interaction, protocol=Protocols.HTTP, href=href_01, content_type=mtype_01) + form_02 = Form(interaction=interaction, protocol=Protocols.HTTP, href=href_01, content_type=mtype_01) + form_03 = Form(interaction=interaction, protocol=Protocols.HTTP, href=href_01, content_type=mtype_02) + form_04 = Form(interaction=interaction, protocol=Protocols.HTTP, href=href_02, content_type=mtype_01) + form_05 = Form(interaction=interaction, protocol=Protocols.HTTP, href=href_02, content_type=mtype_02) + form_06 = Form(interaction=interaction, protocol=Protocols.HTTP, href=href_02, content_type=mtype_02) + + interaction.add_form(form_01) + + with pytest.raises(ValueError): + interaction.add_form(form_02) + + interaction.add_form(form_03) + interaction.add_form(form_04) + interaction.add_form(form_05) + + with pytest.raises(ValueError): + interaction.add_form(form_06) diff --git a/tests/wot/test_wot.py b/tests/wot/test_wot.py new file mode 100644 index 0000000..88afeea --- /dev/null +++ b/tests/wot/test_wot.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +import asyncio +import json +import uuid +import warnings + +import pytest +import tornado.web +from faker import Faker + +from tests.td_examples import TD_EXAMPLE +from tests.utils import find_free_port, run_test_coroutine +from tests.wot.utils import assert_exposed_thing_equal +from wotpy.support import is_dnssd_supported +from wotpy.wot.constants import WOT_TD_CONTEXT_URL_V1_1 +from wotpy.wot.dictionaries.filter import ThingFilterDict +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.enums import DiscoveryMethod +from wotpy.wot.servient import Servient +from wotpy.wot.td import ThingDescription +from wotpy.wot.wot import WoT + +TIMEOUT_DISCOVER = 5 + + +def test_produce_model_str(): + """Things can be produced from TD documents serialized to JSON-LD string.""" + + td_str = json.dumps(TD_EXAMPLE) + thing_name = TD_EXAMPLE.get("title") + + servient = Servient() + wot = WoT(servient=servient) + + assert wot.servient is servient + + exp_thing = wot.produce(td_str) + + assert servient.get_exposed_thing(thing_name) + assert exp_thing.thing.title == thing_name + assert_exposed_thing_equal(exp_thing, TD_EXAMPLE) + + +def test_produce_model_thing_template(): + """Things can be produced from ThingTemplate instances.""" + + thing_id = Faker().url() + thing_title = Faker().sentence() + + thing_template = ThingFragment({ + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": thing_id, + "title": thing_title, + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc" + }) + + servient = Servient() + wot = WoT(servient=servient) + + exp_thing = wot.produce(thing_template) + + assert servient.get_exposed_thing(thing_title) + assert exp_thing.id == thing_id + assert exp_thing.title == thing_title + + +def test_produce_model_consumed_thing(): + """Things can be produced from ConsumedThing instances.""" + + servient = Servient() + wot = WoT(servient=servient) + + td_str = json.dumps(TD_EXAMPLE) + consumed_thing = wot.consume(td_str) + exposed_thing = wot.produce(consumed_thing) + + assert exposed_thing.id == consumed_thing.td.id + assert exposed_thing.title == consumed_thing.td.title + assert len(exposed_thing.properties) == len(consumed_thing.td.properties) + assert len(exposed_thing.actions) == len(consumed_thing.td.actions) + assert len(exposed_thing.events) == len(consumed_thing.td.events) + + +def test_produce_from_url(td_example_tornado_app): + """ExposedThings can be created from URLs that provide Thing Description documents.""" + + app_port = find_free_port() + td_example_tornado_app.listen(app_port) + + url_valid = "http://localhost:{}/".format(app_port) + url_error = "http://localhost:{}/{}".format(app_port, Faker().pystr()) + + wot = WoT(servient=Servient()) + + async def test_coroutine(): + exposed_thing = await wot.produce_from_url(url_valid) + + assert exposed_thing.thing.id == TD_EXAMPLE.get("id") + + with pytest.raises(Exception): + await wot.produce_from_url(url_error) + + run_test_coroutine(test_coroutine) + + +def test_consume_from_url(td_example_tornado_app): + """ConsumedThings can be created from URLs that provide Thing Description documents.""" + + app_port = find_free_port() + td_example_tornado_app.listen(app_port) + + url_valid = "http://localhost:{}/".format(app_port) + url_error = "http://localhost:{}/{}".format(app_port, Faker().pystr()) + + wot = WoT(servient=Servient()) + + async def test_coroutine(): + consumed_thing = await wot.consume_from_url(url_valid) + + assert consumed_thing.td.id == TD_EXAMPLE.get("id") + + with pytest.raises(Exception): + await wot.consume_from_url(url_error) + + run_test_coroutine(test_coroutine) + + +TD_DICT_01 = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": Faker().pystr(), + "security": ["psk_sc"], + "securityDefinitions": { + "psk_sc": {"scheme": "psk"} + }, + "version": {"instance": "1.2.1"}, + "properties": { + "status": { + "description": Faker().pystr(), + "type": "string", + "forms": [{ + "contentType": "application/json", + "href": "http://127.0.0.1/status", + "op": ["readproperty", "writeproperty"] + }] + } + } +} + +TD_DICT_02 = { + "@context": [ + WOT_TD_CONTEXT_URL_V1_1, + ], + "id": uuid.uuid4().urn, + "title": Faker().pystr(), + "securityDefinitions": { + "nosec_sc":{ + "scheme":"nosec" + } + }, + "security": "nosec_sc", + "version": {"instance": "2.0.0"}, + "actions": { + "toggle": { + "output": {"type": "boolean"}, + "forms": [{ + "contentType": "application/json", + "href": "http://127.0.0.1/toggle", + "op": "invokeaction" + }] + } + } +} + + +def assert_equal_tds(one, other): + """Asserts that both TDs are equal.""" + + one = ThingDescription(one) if not isinstance(one, ThingDescription) else one + other = ThingDescription(other) if not isinstance(other, ThingDescription) else other + assert one.to_dict() == other.to_dict() + + +def assert_equal_td_sequences(tds, td_dicts): + """Asserts that the given sequences ot TDs and TD dicts are equal.""" + + assert len(tds) == len(td_dicts) + + for td_dict in td_dicts: + td_match = next(td.to_dict() for td in tds if td.id == td_dict["id"]) + assert_equal_tds(td_match, td_dict) + + +def test_discovery_method_local(): + """All TDs contained in the Servient are returned when using the local + discovery method without defining the fragment nor the query fields.""" + + servient = Servient(dnssd_enabled=False) + wot = WoT(servient=servient) + wot.produce(ThingFragment(TD_DICT_01)) + wot.produce(ThingFragment(TD_DICT_02)) + + def resolve(future_done, found): + len(found) == 2 and not future_done.done() and future_done.set_result(True) + + async def test_coroutine(): + loop = asyncio.get_running_loop() + future_done, found = loop.create_future(), [] + + thing_filter = ThingFilterDict(method=DiscoveryMethod.LOCAL) + observable = wot.discover(thing_filter) + + subscription = observable.subscribe( + on_next=lambda td_str: + found.append(ThingDescription(td_str)) or resolve(future_done, found)) + + await future_done + + assert_equal_td_sequences(found, [TD_DICT_01, TD_DICT_02]) + + subscription.dispose() + + run_test_coroutine(test_coroutine) + + +@pytest.mark.skipif(not is_dnssd_supported(), reason="Only for platforms that support DNS-SD") +def test_discovery_method_multicast_dnssd(): + """Things can be discovered usin the multicast method supported by DNS-SD.""" + + catalogue_port_01 = find_free_port() + catalogue_port_02 = find_free_port() + + instance_name_01 = "servient-01-{}".format(Faker().pystr()) + instance_name_02 = "servient-02-{}".format(Faker().pystr()) + + servient_01 = Servient( + catalogue_port=catalogue_port_01, + dnssd_enabled=True, + dnssd_instance_name=instance_name_01) + + servient_02 = Servient( + catalogue_port=catalogue_port_02, + dnssd_enabled=True, + dnssd_instance_name=instance_name_02) + + def resolve(future_done, found): + len(found) == 2 and not future_done.done() and future_done.set_result(True) + + async def test_coroutine(): + loop = asyncio.get_running_loop() + future_done, found = loop.create_future(), [] + + wot_01 = await servient_01.start() + wot_02 = await servient_02.start() + + wot_01.produce(ThingFragment(TD_DICT_01)).expose() + wot_01.produce(ThingFragment(TD_DICT_02)).expose() + + thing_filter = ThingFilterDict(method=DiscoveryMethod.MULTICAST) + + observable = wot_02.discover(thing_filter, dnssd_find_kwargs={ + "min_results": 1, + "timeout": 5 + }) + + subscription = observable.subscribe( + on_next=lambda td_str: + found.append(ThingDescription(td_str)) or resolve(future_done, found) + ) + + await future_done + + assert_equal_td_sequences(found, [TD_DICT_01, TD_DICT_02]) + + subscription.dispose() + + await servient_01.shutdown() + await servient_02.shutdown() + + run_test_coroutine(test_coroutine) + + +@pytest.mark.skipif(is_dnssd_supported(), reason="Only for platforms that do not support DNS-SD") +def test_discovery_method_multicast_dnssd_unsupported(): + """Attempting to discover other Things using multicast + DNS-SD in an unsupported platform raises a warning.""" + + servient = Servient(catalogue_port=None, dnssd_enabled=True) + + async def test_coroutine(): + wot = await servient.start() + + with warnings.catch_warnings(record=True) as warns: + wot.discover(ThingFilterDict(method=DiscoveryMethod.MULTICAST)) + assert len(warns) + + await servient.shutdown() + + run_test_coroutine(test_coroutine) + + +def test_discovery_fragment(): + """The Thing filter fragment attribute enables discovering Things by matching TD fields.""" + + servient = Servient(dnssd_enabled=False) + wot = WoT(servient=servient) + wot.produce(ThingFragment(TD_DICT_01)) + wot.produce(ThingFragment(TD_DICT_02)) + + async def test_coroutine(): + async def first(thing_filter): + """Returns the first TD discovery for the given Thing filter.""" + + def resolve(future_done): + not future_done.done() and future_done.set_result(True) + + async def discover_first(): + loop = asyncio.get_running_loop() + future_done, found = loop.create_future(), [] + + observable = wot.discover(thing_filter) + + subscription = observable.subscribe( + on_next=lambda td_str: + found.append(ThingDescription(td_str)) or resolve(future_done)) + + await future_done + + subscription.dispose() + + assert len(found) + + return found[0] + + thing = asyncio.create_task(discover_first()) + return await asyncio.wait_for(thing, timeout=TIMEOUT_DISCOVER) + + fragment_td_pairs = [ + ({"title": TD_DICT_01.get("title")}, TD_DICT_01), + ({"version": {"instance": "2.0.0"}}, TD_DICT_02), + ({"id": TD_DICT_02.get("id")}, TD_DICT_02), + ({"securityDefinitions": {"psk_sc": {"scheme": "psk"}}}, TD_DICT_01) + ] + + for fragment, td_expected in fragment_td_pairs: + td_found = await first(ThingFilterDict(method=DiscoveryMethod.LOCAL, fragment=fragment)) + assert_equal_tds(td_found, td_expected) + + run_test_coroutine(test_coroutine) diff --git a/tests/wot/utils.py b/tests/wot/utils.py new file mode 100644 index 0000000..906f6b3 --- /dev/null +++ b/tests/wot/utils.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + + +def assert_exposed_thing_equal(exp_thing, td_doc): + """Asserts that the given ExposedThing is equivalent to the Thing Description dict.""" + + assert exp_thing.thing.id == td_doc.get("id") + assert exp_thing.thing.title == td_doc.get("title", None) + assert exp_thing.thing.description == td_doc.get("description", None) + assert len(exp_thing.thing.properties) == len(td_doc.get("properties", [])) + assert len(exp_thing.thing.actions) == len(td_doc.get("actions", [])) + assert len(exp_thing.thing.events) == len(td_doc.get("events", [])) diff --git a/version.sh b/version.sh new file mode 100755 index 0000000..35297ab --- /dev/null +++ b/version.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +set -e +set -x + +if [ -n "$(git status --porcelain)" ]; then + echo "Git working directory should be clean" + exit 1 +fi + +if [[ !("$1" =~ ^(major|minor|patch)$) ]]; then + echo "Part should be one of major, minor or patch" + exit 1 +fi + +if [[ "develop" != $(git branch --show-current) ]]; then + echo "Current branch is not develop" + exit 1 +fi + +bump2version $1 + +git checkout master \ +&& git merge develop \ +&& git checkout develop \ +&& git push origin --all \ +&& git push origin --tags diff --git a/wotpy/__init__.py b/wotpy/__init__.py new file mode 100644 index 0000000..50eb685 --- /dev/null +++ b/wotpy/__init__.py @@ -0,0 +1,36 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import logging +import os + +logger_base = logging.getLogger(__name__) +logger_base.addHandler(logging.NullHandler()) + +if os.environ.get("WOTPY_ENABLE_UVLOOP", False): + try: + import asyncio + import uvloop + + logger_base.warning("Installing uvloop (this is an experimental feature)") + uvloop.install() + except ImportError: + logger_base.warning("Error installing uvloop (cannot import package)") diff --git a/wotpy/__version__.py b/wotpy/__version__.py new file mode 100644 index 0000000..71545b3 --- /dev/null +++ b/wotpy/__version__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +__version__ = "0.16.0" diff --git a/wotpy/cli/__init__.py b/wotpy/cli/__init__.py new file mode 100644 index 0000000..9af3803 --- /dev/null +++ b/wotpy/cli/__init__.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +CLI class to configure and create WoT clients and servers. + +.. autosummary:: + :toctree: _cli + + wotpy.cli.cli + wotpy.cli.default_servient +""" diff --git a/wotpy/cli/cli.py b/wotpy/cli/cli.py new file mode 100644 index 0000000..34ad697 --- /dev/null +++ b/wotpy/cli/cli.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +CLI for the WoT runtime that handles servient configuration and execution of WoT runtime scripts. +""" + +import asyncio +import yaml +import argparse +import importlib.util + +from wotpy.cli.default_servient import DefaultServient + +async def run_script(script_path, config_path): + """Creates a Servient based on the config file and runs the input script. + The script must contain an async function named `main` that accepts the + argument `wot`.""" + + config = {} + if config_path is not None: + with open(config_path, "r") as config_file: + config = yaml.safe_load(config_file) + + default_servient = DefaultServient(config) + wot = await default_servient.start() + + spec = importlib.util.spec_from_file_location("wot_script", script_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + loop = asyncio.get_running_loop() + loop.create_task(module.main(wot)) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run a WoT script optionally preconfigured by a config file.") + parser.add_argument("script", help="input WoT script file") + parser.add_argument("-f", "--config-file", help="path to the configuration file") + args = parser.parse_args() + + loop = asyncio.get_event_loop() + loop.create_task(run_script(args.script, args.config_file)) + loop.run_forever() diff --git a/wotpy/cli/default_servient.py b/wotpy/cli/default_servient.py new file mode 100644 index 0000000..c0d1193 --- /dev/null +++ b/wotpy/cli/default_servient.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Default Servient class +""" + +import ssl +import urllib.parse + +from wotpy.protocols.http.client import HTTPClient +from wotpy.protocols.http.server import HTTPServer +from wotpy.protocols.coap.client import CoAPClient +from wotpy.protocols.coap.server import CoAPServer +from wotpy.protocols.mqtt.client import MQTTClient +from wotpy.protocols.mqtt.server import MQTTServer +from wotpy.protocols.ws.client import WebsocketClient +from wotpy.protocols.ws.server import WebsocketServer +from wotpy.wot.enums import SecuritySchemeType +from wotpy.wot.servient import Servient + + +class DefaultServient(Servient): + """Servient with preconfigured values.""" + + DEFAULT_CONFIG = { + "servient": { + "clientOnly": False + }, + "http": { + "port": 8080, + "enabled": True, + "security": { + "scheme": SecuritySchemeType.NOSEC + } + }, + "coap": { + "port": 5683, + "enabled": True, + "security": { + "scheme": SecuritySchemeType.NOSEC + } + }, + "mqtt": { + "enabled": False + }, + "ws": { + "port": 8081, + "enabled": False + } + } + + def __init__(self, config): + new_config = dict(self.DEFAULT_CONFIG) + new_config.update(config) + + ssl_context = None + if "serverCert" in new_config and "serverKey" in new_config: + certfile = new_config["serverCert"] + keyfile = new_config["serverKey"] + + ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) + + servers = [] + if not new_config["servient"]["clientOnly"]: + if new_config["http"]["enabled"]: + port = new_config["http"]["port"] + security_scheme = new_config["http"]["security"] + + servers.append(HTTPServer( + port=port, security_scheme=security_scheme, ssl_context=ssl_context)) + + if new_config["coap"]["enabled"]: + port = new_config["coap"]["port"] + security_scheme = new_config["coap"]["security"] + + servers.append(CoAPServer(port=port, security_scheme=security_scheme)) + + if new_config["mqtt"]["enabled"]: + broker_url = new_config["mqtt"]["brokerUrl"] + + if "username" in new_config["mqtt"] and "password" in new_config["mqtt"]: + username = new_config["mqtt"]["username"] + password = new_config["mqtt"]["password"] + + url_parts = list(urllib.parse.urlparse(broker_url)) + url_parts[1] = f"{username}:{password}@{url_parts[1]}" + broker_url = urllib.parse.urlunparse(url_parts) + + servers.append(MQTTServer(broker_url)) + + if new_config["ws"]["enabled"]: + port = new_config["ws"]["port"] + + servers.append(WebsocketServer(port=port, ssl_context=ssl_context)) + + clients = [ + HTTPClient(), + CoAPClient(), + MQTTClient(), + WebsocketClient() + ] + + super().__init__(clients=clients) + + for server in servers: + self.add_server(server) + + if "credentials" in new_config: + self.add_credentials(new_config["credentials"]) diff --git a/wotpy/codecs/__init__.py b/wotpy/codecs/__init__.py new file mode 100644 index 0000000..da2386b --- /dev/null +++ b/wotpy/codecs/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes to serialize and deserialize messages. + +.. autosummary:: + :toctree: _codecs + + wotpy.codecs.base + wotpy.codecs.enums + wotpy.codecs.json_codec + wotpy.codecs.text +""" diff --git a/wotpy/codecs/base.py b/wotpy/codecs/base.py new file mode 100644 index 0000000..4ad3c92 --- /dev/null +++ b/wotpy/codecs/base.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents the codec interface. +""" + + +class BaseCodec: + """Base codec abstract class. + All codecs must implement this interface.""" + + @property + def media_types(self): + """Property getter for the supported media types of this codec.""" + + raise NotImplementedError() + + def to_value(self, value): + """Takes an encoded value from a request that may be an UTF8 + bytes or unicode string and decodes it to a Python object.""" + + raise NotImplementedError() + + def to_bytes(self, value): + """Takes a Python object and encodes it to an UTF8 + bytes string to be included in a response.""" + + raise NotImplementedError() diff --git a/wotpy/codecs/enums.py b/wotpy/codecs/enums.py new file mode 100644 index 0000000..6642b1d --- /dev/null +++ b/wotpy/codecs/enums.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Enumeration classes related to codecs. +""" + +from wotpy.utils.enums import EnumListMixin + + +class MediaTypes(EnumListMixin): + """Enumeration of media types.""" + + JSON = "application/json" + TEXT = "text/plain" diff --git a/wotpy/codecs/json_codec.py b/wotpy/codecs/json_codec.py new file mode 100644 index 0000000..fc9132a --- /dev/null +++ b/wotpy/codecs/json_codec.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that implements the JSON codec. +""" + +import json + +from wotpy.codecs.base import BaseCodec +from wotpy.codecs.enums import MediaTypes + + +class JsonCodec(BaseCodec): + """JSON codec class.""" + + @property + def media_types(self): + """Returns the JSON media types.""" + + return [MediaTypes.JSON] + + def to_value(self, value): + """Takes an encoded value from a request that may be an UTF8 bytes + or unicode JSON string and deserializes it to a Python object.""" + + return json.loads(value) + + def to_bytes(self, value): + """Takes an object and serializes it to an UTF8 bytes JSON string.""" + + json_str = json.dumps(value) + + return json_str if isinstance(json_str, bytes) else json_str.encode('utf8') diff --git a/wotpy/codecs/text.py b/wotpy/codecs/text.py new file mode 100644 index 0000000..a85d6f9 --- /dev/null +++ b/wotpy/codecs/text.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that implements the text codec. +""" + +from wotpy.codecs.base import BaseCodec +from wotpy.codecs.enums import MediaTypes + + +class TextCodec(BaseCodec): + """Text codec class.""" + + @property + def media_types(self): + """Returns the text media types.""" + + return [MediaTypes.TEXT] + + def to_value(self, value): + """Takes an encoded value from a request that may be a UTF8 bytes + or unicode string and decodes it to an unicode string.""" + + return value.decode('utf8') if isinstance(value, bytes) else value + + def to_bytes(self, value): + """Takes an unicode string and encodes it to an UTF8 bytes string.""" + + return value.encode('utf8') diff --git a/wotpy/protocols/__init__.py b/wotpy/protocols/__init__.py new file mode 100644 index 0000000..7e5c50c --- /dev/null +++ b/wotpy/protocols/__init__.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Implementations of the supported Protocol Binding templates. + +.. autosummary:: + :toctree: _protocols + + wotpy.protocols.coap + wotpy.protocols.http + wotpy.protocols.mqtt + wotpy.protocols.ws + wotpy.protocols.client + wotpy.protocols.enums + wotpy.protocols.exceptions + wotpy.protocols.server + wotpy.protocols.utils +""" diff --git a/wotpy/protocols/client.py b/wotpy/protocols/client.py new file mode 100644 index 0000000..6dfd9bc --- /dev/null +++ b/wotpy/protocols/client.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents the abstract client interface. +""" + +from abc import ABCMeta, abstractmethod + + +class BaseProtocolClient(metaclass=ABCMeta): + """Base protocol client class. + This is the interface that must be implemented by all client classes.""" + + @property + @abstractmethod + def protocol(self): + """Protocol of this client instance. + A member of the Protocols enum.""" + + raise NotImplementedError() + + @abstractmethod + def is_supported_interaction(self, td, name): + """Returns True if the any of the Forms for the Interaction + with the given name is supported in this Protocol Binding client.""" + + raise NotImplementedError() + + def set_security(self, security_scheme_dict, credentials): + """Sets the security credentials for the given security scheme.""" + + raise NotImplementedError() + + @abstractmethod + def invoke_action(self, td, name, input_value, timeout=None): + """Invokes an Action on a remote Thing. + Returns a Future.""" + + raise NotImplementedError() + + @abstractmethod + def write_property(self, td, name, value, timeout=None): + """Updates the value of a Property on a remote Thing. + Returns a Future.""" + + raise NotImplementedError() + + @abstractmethod + def read_property(self, td, name, timeout=None): + """Reads the value of a Property on a remote Thing. + Returns a Future.""" + + raise NotImplementedError() + + @abstractmethod + def on_event(self, td, name): + """Subscribes to an event on a remote Thing. + Returns an Observable.""" + + raise NotImplementedError() + + @abstractmethod + def on_property_change(self, td, name): + """Subscribes to property changes on a remote Thing. + Returns an Observable""" + + raise NotImplementedError() + + @abstractmethod + def on_td_change(self, url): + """Subscribes to Thing Description changes on a remote Thing. + Returns an Observable.""" + + raise NotImplementedError() diff --git a/wotpy/protocols/coap/__init__.py b/wotpy/protocols/coap/__init__.py new file mode 100644 index 0000000..2066838 --- /dev/null +++ b/wotpy/protocols/coap/__init__.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +CoAP Protocol Binding implementation. + +.. autosummary:: + :toctree: _coap + + wotpy.protocols.coap.resources + wotpy.protocols.coap.client + wotpy.protocols.coap.enums + wotpy.protocols.coap.server +""" + +from wotpy.support import is_coap_supported + +if is_coap_supported() is False: + raise NotImplementedError("CoAP binding is not supported in this platform") diff --git a/wotpy/protocols/coap/authenticator.py b/wotpy/protocols/coap/authenticator.py new file mode 100644 index 0000000..9877caa --- /dev/null +++ b/wotpy/protocols/coap/authenticator.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Authenticator classes that perform checks on the incoming requests. +""" + +from base64 import b64decode +from abc import ABCMeta, abstractmethod + +from wotpy.wot.enums import SecuritySchemeType + +class BaseAuthenticator(metaclass=ABCMeta): + """This is the base authenticator class describing + the authentication interface.""" + + def __init__(self, security_scheme_dict): + self._security_scheme_dict = security_scheme_dict + + @abstractmethod + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + @classmethod + def build(cls, security_scheme_dict): + """Builds an instance of the appropriate subclass for the given SecurityScheme.""" + + klass_map = { + SecuritySchemeType.NOSEC: NoSecurityAuthenticator, + SecuritySchemeType.AUTO: AutoSecurityAuthenticator, + SecuritySchemeType.COMBO: ComboSecurityAuthenticator, + SecuritySchemeType.BASIC: BasicSecurityAuthenticator, + SecuritySchemeType.DIGEST: DigestSecurityAuthenticator, + SecuritySchemeType.APIKEY: APIKeySecurityAuthenticator, + SecuritySchemeType.BEARER: BearerSecurityAuthenticator, + SecuritySchemeType.PSK: PSKSecurityAuthenticator, + SecuritySchemeType.OAUTH2: OAuth2SecurityAuthenticator + } + + scheme_type = security_scheme_dict.get("scheme") + klass = klass_map.get(scheme_type) + + if not klass: + raise ValueError("Unknown scheme: {}".format(scheme_type)) + + return klass(security_scheme_dict) + + +class NoSecurityAuthenticator(BaseAuthenticator): + """Authenticator that allows all requests.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + return True + + +class AutoSecurityAuthenticator(BaseAuthenticator): + """Auto security authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class ComboSecurityAuthenticator(BaseAuthenticator): + """Combinator of security schemes authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class BasicSecurityAuthenticator(BaseAuthenticator): + """Basic username and password authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + server_username = server_creds.get("username", None) + server_password = server_creds.get("password", None) + valid_server_creds = server_username is not None and server_password is not None + + option = request.opt.get_option(2048) + if not option: + return False + + auth_header = option[0].value + if not auth_header.startswith(b"Basic "): + return False + + auth_header = auth_header.replace(b"Basic ", b"") + decoded_string = b64decode(auth_header).decode("ascii") + username, password = decoded_string.split(":", 1) + + creds_match = (server_username == username and server_password == password) + + return valid_server_creds and creds_match + + +class DigestSecurityAuthenticator(BaseAuthenticator): + """Digest authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class APIKeySecurityAuthenticator(BaseAuthenticator): + """API Key authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class BearerSecurityAuthenticator(BaseAuthenticator): + """Bearer token authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class PSKSecurityAuthenticator(BaseAuthenticator): + """Pre shared key authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class OAuth2SecurityAuthenticator(BaseAuthenticator): + """OAuth2 authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() diff --git a/wotpy/protocols/coap/client.py b/wotpy/protocols/coap/client.py new file mode 100644 index 0000000..3b8284e --- /dev/null +++ b/wotpy/protocols/coap/client.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that contain the client logic for the CoAP protocol. +""" + +import asyncio +import json +import logging +import time +from urllib.parse import urlparse + +import aiocoap +import reactivex + +from wotpy.protocols.client import BaseProtocolClient +from wotpy.protocols.coap.enums import CoAPSchemes +from wotpy.protocols.coap.credential import BaseCredential +from wotpy.protocols.enums import Protocols, InteractionVerbs +from wotpy.protocols.exceptions import FormNotFoundException, ProtocolClientException, ClientRequestTimeout +from wotpy.protocols.utils import is_scheme_form +from wotpy.utils.utils import handle_observer_finalization +from wotpy.wot.events import PropertyChangeEventInit, PropertyChangeEmittedEvent, EmittedEvent + + +# noinspection PyCompatibility +class CoAPClient(BaseProtocolClient): + """Implementation of the protocol client interface for the CoAP protocol.""" + + def __init__(self): + self._logr = logging.getLogger(__name__) + self._coap_client = None + self._client_lock = asyncio.Lock() + self._credential = None + super(CoAPClient, self).__init__() + + @classmethod + def _pick_coap_href(cls, td, forms, op=None): + """Picks the most appropriate CoAP form href from the given list of forms.""" + + def is_op_form(form): + try: + return op is None or op == form.op or op in form.op + except TypeError: + return False + + def find_href(scheme): + try: + return next( + form.href for form in forms + if is_scheme_form(form, td.base, scheme) and is_op_form(form)) + except StopIteration: + return None + + form_coaps = find_href(CoAPSchemes.COAPS) + + return form_coaps if form_coaps is not None else find_href(CoAPSchemes.COAP) + + @classmethod + def _assert_success(cls, res): + """Asserts that the given CoAP response was successful and raises an Exception if not.""" + + if not res.code.is_successful(): + raise ProtocolClientException("Unsuccessful CoAP response: {}".format(res)) + + def _build_subscribe(self, href, next_item_builder): + """Builds the subscribe function that should be passed when + constructing an Observable linked to an observable CoAP resurce.""" + + def subscribe(observer, scheduler): + """Subscription function to observe resources using the CoAP protocol.""" + + query = urlparse(href).query + + state = { + "active": True, + "request": None, + "pending": None + } + + @handle_observer_finalization(observer) + async def callback(): + self._logr.debug("Creating CoAP client for observation: {}".format(query)) + + coap_client = await aiocoap.Context.create_client_context() + + try: + msg = aiocoap.Message(code=aiocoap.Code.GET, uri=href, observe=0) + state["request"] = coap_client.request(self.sign_request(msg)) + + self._logr.debug("Sending observation request: {}".format(msg)) + + future_first_resp = state["request"].response + state["pending"] = future_first_resp + first_resp = await future_first_resp + state["pending"] = None + self._assert_success(first_resp) + next_item = next_item_builder(first_resp.payload) + next_item is not None and observer.on_next(next_item) + + while state["active"]: + next_obsv_gen = state["request"].observation.__aiter__().__anext__() + future_resp = asyncio.ensure_future(next_obsv_gen) + state["pending"] = future_resp + resp = await future_resp + state["pending"] = None + self._assert_success(resp) + next_item = next_item_builder(resp.payload) + next_item is not None and observer.on_next(next_item) + + self._logr.debug("Terminated subscription callback for: {}".format(query)) + finally: + await coap_client.shutdown() + + def unsubscribe(): + self._logr.debug("Unsubscribing from: {}".format(query)) + + state["active"] = False + + if state["request"] and not state["request"].observation.cancelled: + self._logr.debug("Cancelling observation on: {}".format(query)) + state["request"].observation.cancel() + + if state["pending"]: + self._logr.debug("Cancelling pending request: {}".format(state["pending"])) + state["pending"].cancel() + + asyncio.create_task(callback()) + + return unsubscribe + + return subscribe + + @property + def protocol(self): + """Protocol of this client instance. + A member of the Protocols enum.""" + + return Protocols.COAP + + def is_supported_interaction(self, td, name): + """Returns True if the any of the Forms for the Interaction + with the given name is supported in this Protocol Binding client.""" + + forms = td.get_forms(name) + + forms_coap = [ + form for form in forms + if is_scheme_form(form, td.base, CoAPSchemes.list()) + ] + + return len(forms_coap) > 0 + + def set_security(self, security_scheme_dict, credentials): + """Sets the security credentials for the given security scheme.""" + + credential = BaseCredential.build(security_scheme_dict, credentials) + self._credential = credential + + def sign_request(self, request): + """Adds the appropriate authorization header to the request + and delegates the addition of the header to the credential class.""" + + if self._credential: + return self._credential.sign(request) + + return request + + async def _invocation_create(self, coap_client, href, input_value, timeout=None): + """Creates a new action invocation by sending a POST request.""" + + payload = json.dumps({"input": input_value}).encode("utf-8") + msg = aiocoap.Message(code=aiocoap.Code.POST, payload=payload, uri=href) + request = coap_client.request(self.sign_request(msg)) + + try: + response = await asyncio.wait_for(request.response, timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout + + self._assert_success(response) + + invocation_id = json.loads(response.payload).get("id") + + return invocation_id + + async def _invocation_observe(self, coap_client, href, invocation_id, timeout=None): + """Starts observing an existing action invocation by sending a GET request.""" + + payload = json.dumps({"id": invocation_id}).encode("utf-8") + msg = aiocoap.Message(code=aiocoap.Code.GET, payload=payload, uri=href, observe=0) + request = coap_client.request(self.sign_request(msg)) + + try: + response = await asyncio.wait_for(request.response, timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout + + self._assert_success(response) + + return request, response + + async def _invocation_next(self, request, timeout=None): + """Waits for the next item in an active action invocation observation.""" + + try: + response = await asyncio.wait_for( + request.observation.__aiter__().__anext__(), + timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout + + self._assert_success(response) + + return response + + async def invoke_action(self, td, name, input_value, timeout=None): + """Invokes an Action on a remote Thing.""" + + href = self._pick_coap_href( + td, td.get_action_forms(name), + op=InteractionVerbs.INVOKE_ACTION) + + if href is None: + raise FormNotFoundException() + + coap_client = await aiocoap.Context.create_client_context() + + try: + invocation_id = await self._invocation_create( + coap_client, href, input_value, timeout=timeout) + + request_obsv, response_obsv = await self._invocation_observe( + coap_client, href, invocation_id, timeout=timeout) + + invocation_status = json.loads(response_obsv.payload) + + now = time.time() + + while not invocation_status.get("done"): + if timeout and (time.time() - now) > timeout: + raise ClientRequestTimeout + + response_obsv = await self._invocation_next(request_obsv, timeout=timeout) + invocation_status = json.loads(response_obsv.payload) + + if not request_obsv.observation.cancelled: + request_obsv.observation.cancel() + + if invocation_status.get("error"): + raise Exception(invocation_status.get("error")) + else: + return invocation_status.get("result") + finally: + await coap_client.shutdown() + + async def write_property(self, td, name, value, timeout=None): + """Updates the value of a Property on a remote Thing.""" + + href = self._pick_coap_href( + td, td.get_property_forms(name), + op=InteractionVerbs.WRITE_PROPERTY) + + if href is None: + raise FormNotFoundException() + + coap_client = await aiocoap.Context.create_client_context() + + try: + payload = json.dumps({"value": value}).encode("utf-8") + msg = aiocoap.Message(code=aiocoap.Code.PUT, payload=payload, uri=href) + request = coap_client.request(self.sign_request(msg)) + + try: + response = await asyncio.wait_for(request.response, timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout + + self._assert_success(response) + finally: + await coap_client.shutdown() + + async def read_property(self, td, name, timeout=None): + """Reads the value of a Property on a remote Thing.""" + + href = self._pick_coap_href( + td, td.get_property_forms(name), + op=InteractionVerbs.READ_PROPERTY) + + if href is None: + raise FormNotFoundException() + + coap_client = await aiocoap.Context.create_client_context() + + try: + msg = aiocoap.Message(code=aiocoap.Code.GET, uri=href) + request = coap_client.request(self.sign_request(msg)) + + try: + response = await asyncio.wait_for(request.response, timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout + + self._assert_success(response) + + prop_value = json.loads(response.payload).get("value") + + return prop_value + finally: + await coap_client.shutdown() + + def on_property_change(self, td, name): + """Subscribes to property changes on a remote Thing. + Returns an Observable""" + + href = self._pick_coap_href( + td, td.get_property_forms(name), + op=InteractionVerbs.OBSERVE_PROPERTY) + + if href is None: + raise FormNotFoundException() + + def next_item_builder(payload): + value = json.loads(payload).get("value") + init = PropertyChangeEventInit(name=name, value=value) + return PropertyChangeEmittedEvent(init=init) + + subscribe = self._build_subscribe(href, next_item_builder) + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_event(self, td, name): + """Subscribes to an event on a remote Thing. + Returns an Observable.""" + + href = self._pick_coap_href( + td, td.get_event_forms(name), + op=InteractionVerbs.SUBSCRIBE_EVENT) + + if href is None: + raise FormNotFoundException() + + def next_item_builder(payload): + if payload: + data = json.loads(payload).get("data") + return EmittedEvent(init=data, name=name) + else: + return None + + subscribe = self._build_subscribe(href, next_item_builder) + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_td_change(self, url): + """Subscribes to Thing Description changes on a remote Thing. + Returns an Observable.""" + + raise NotImplementedError diff --git a/wotpy/protocols/coap/credential.py b/wotpy/protocols/coap/credential.py new file mode 100644 index 0000000..f9e3054 --- /dev/null +++ b/wotpy/protocols/coap/credential.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Credential classes that add the proper authorization creds to the outgoing requests. +""" + +from base64 import b64encode +from abc import ABCMeta, abstractmethod + +from aiocoap.optiontypes import StringOption + +from wotpy.wot.enums import SecuritySchemeType + + +class BaseCredential(metaclass=ABCMeta): + """This is the base credential class describing + the credential interface.""" + + def __init__(self, security_credentials): + self._security_credentials = security_credentials + + @abstractmethod + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + @classmethod + def build(cls, security_scheme_dict, security_credentials): + """Builds an instance of the appropriate subclass for the given SecurityScheme.""" + + klass_map = { + SecuritySchemeType.NOSEC: NoSecurityCredential, + SecuritySchemeType.AUTO: AutoSecurityCredential, + SecuritySchemeType.COMBO: ComboSecurityCredential, + SecuritySchemeType.BASIC: BasicSecurityCredential, + SecuritySchemeType.DIGEST: DigestSecurityCredential, + SecuritySchemeType.APIKEY: APIKeySecurityCredential, + SecuritySchemeType.BEARER: BearerSecurityCredential, + SecuritySchemeType.PSK: PSKSecurityCredential, + SecuritySchemeType.OAUTH2: OAuth2SecurityCredential + } + + scheme_type = security_scheme_dict.get("scheme") + klass = klass_map.get(scheme_type) + + if not klass: + raise ValueError("Unknown scheme: {}".format(scheme_type)) + + return klass(security_credentials) + + +class NoSecurityCredential(BaseCredential): + """Credential that allows all requests.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + return request + + +class AutoSecurityCredential(BaseCredential): + """Auto security credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class ComboSecurityCredential(BaseCredential): + """Combinator of security schemes credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class BasicSecurityCredential(BaseCredential): + """Basic username and password credential.""" + + def __init__(self, security_credentials): + self._username = security_credentials.get("username", None) + self._password = security_credentials.get("password", None) + + assert self._username is not None and self._password is not None + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + encoded_creds = b64encode(f"{self._username}:{self._password}".encode("ascii")) + encoded_creds_str = encoded_creds.decode("ascii") + auth_header = f"Basic {encoded_creds_str}" + request.opt.add_option(StringOption(2048, auth_header)) + + return request + + +class DigestSecurityCredential(BaseCredential): + """Digest credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class APIKeySecurityCredential(BaseCredential): + """API Key credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class BearerSecurityCredential(BaseCredential): + """Bearer token credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class PSKSecurityCredential(BaseCredential): + """Pre shared key credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class OAuth2SecurityCredential(BaseCredential): + """OAuth2 credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() diff --git a/wotpy/protocols/coap/enums.py b/wotpy/protocols/coap/enums.py new file mode 100644 index 0000000..e9785de --- /dev/null +++ b/wotpy/protocols/coap/enums.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Enumeration classes related to the CoAP server. +""" + +from wotpy.utils.enums import EnumListMixin + + +class CoAPSchemes(EnumListMixin): + """Enumeration of CoAP schemes.""" + + COAP = "coap" + COAPS = "coaps" diff --git a/wotpy/protocols/coap/resources/__init__.py b/wotpy/protocols/coap/resources/__init__.py new file mode 100644 index 0000000..56e15be --- /dev/null +++ b/wotpy/protocols/coap/resources/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +CoAP resources that implement each of the Interaction verbs. + +.. autosummary:: + :toctree: _resources + + wotpy.protocols.coap.resources.action + wotpy.protocols.coap.resources.event + wotpy.protocols.coap.resources.property + wotpy.protocols.coap.resources.utils +""" diff --git a/wotpy/protocols/coap/resources/action.py b/wotpy/protocols/coap/resources/action.py new file mode 100644 index 0000000..206a08f --- /dev/null +++ b/wotpy/protocols/coap/resources/action.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +CoAP resources to deal with Action interactions. +""" + +import asyncio +import datetime +import json +import logging +import uuid + +import aiocoap +import aiocoap.error +import aiocoap.resource + +from wotpy.protocols.coap.resources.utils import parse_request_opt_query + +JSON_CONTENT_FORMAT = 50 + + +async def get_thing_action(server, request): + """Takes a CoAP request and returns the Thing Action + identified by the request arguments.""" + + query = parse_request_opt_query(request) + url_name_thing = query.get("thing") + url_name_action = query.get("name") + + if not url_name_thing or not url_name_action: + raise aiocoap.error.BadRequest("Missing query arguments") + + exposed_thing = server.exposed_thing_set.find_by_thing_name(url_name_thing) + + if not exposed_thing: + raise aiocoap.error.NotFound("Thing not found") + + valid_creds = await server._check_credentials(exposed_thing.title, request) + if not valid_creds: + raise aiocoap.error.Unauthorized("Authentication required") + + try: + return next( + exposed_thing.actions[key] for key in exposed_thing.actions + if exposed_thing.actions[key].url_name == url_name_action) + except StopIteration: + raise aiocoap.error.NotFound("Action not found") + + +class ActionResource(aiocoap.resource.ObservableResource): + """CoAP resource to invoke Actions and observe those invocations.""" + + DEFAULT_CLEAR_MS = 1000 * 60 * 5 + + def __init__(self, server, clear_ms=None): + super(ActionResource, self).__init__() + self._server = server + self._clear_ms = self.DEFAULT_CLEAR_MS if clear_ms is None else clear_ms + self._pending_actions = {} + self._logr = logging.getLogger(__name__) + + async def render_get(self, request): + """Handler to check the status of an ongoing invocation.""" + + request_payload = json.loads(request.payload) + invocation_id = request_payload.get("id", None) + + self._logr.debug("Action GET request for invocation: {}".format(invocation_id)) + + if invocation_id is None: + raise aiocoap.error.BadRequest("Missing invocation ID") + + if invocation_id not in self._pending_actions: + raise aiocoap.error.NotFound("Unknown invocation") + + future_result = self._pending_actions[invocation_id] + + def raise_response(the_resp_dict): + response_payload = json.dumps(the_resp_dict).encode("utf-8") + response = aiocoap.Message(code=aiocoap.Code.CONTENT, payload=response_payload) + response.opt.content_format = JSON_CONTENT_FORMAT + return response + + if not future_result.done(): # TODO propably change + self._logr.debug("Invocation ({}) is still pending".format(invocation_id)) + return raise_response({"id": invocation_id, "done": False}) + + resp_dict = {"done": True, "id": invocation_id} + + try: + result = future_result.result() + resp_dict.update({"result": result}) + except Exception as ex: + resp_dict.update({"error": str(ex)}) + + self._logr.debug("Returning invocation: {}".format(invocation_id)) + + return raise_response(resp_dict) + + async def add_observation(self, request, server_observation): + """Method that decides whether to add a new observer. + Observers are added for GET requests (checks for invocation status) + but not for POST requests (action invocations).""" + + if request.code.name != aiocoap.Code.GET.name: + return + + try: + request_payload = json.loads(request.payload) + except (TypeError, json.decoder.JSONDecodeError): + return + + invocation_id = request_payload.get("id", None) + + if invocation_id not in self._pending_actions: + self._logr.debug("Observation rejected (unknown invocation): {}".format(invocation_id)) + return + + def cancellation_cb(): + self._logr.debug("Observation cancel callback for invocation: {}".format(invocation_id)) + + self._logr.debug("Added observation for invocation: {}".format(invocation_id)) + + server_observation.accept(cancellation_cb) + + # noinspection PyUnusedLocal + def trigger_cb(fut): + self._logr.debug("Triggering observation for invocation: {}".format(invocation_id)) + server_observation.trigger() + + future_result = self._pending_actions[invocation_id] + future_result.add_done_callback(trigger_cb) + + async def render_post(self, request): + """Handler for action invocations.""" + + thing_action = await get_thing_action(self._server, request) + + self._logr.debug("Action POST request: {}".format(thing_action)) + + request_payload = json.loads(request.payload) + + if "input" not in request_payload: + raise aiocoap.error.BadRequest("Missing input value") + + invocation_id = uuid.uuid4().hex + + def clear_cb(): + self._logr.debug("Removing pending invocation: {}".format(invocation_id)) + self._pending_actions.pop(invocation_id, None) + + # noinspection PyUnusedLocal + def done_cb(fut): + loop = asyncio.get_running_loop() + self._logr.debug("Invocation done ({}): cleaning on {} ms".format(invocation_id, self._clear_ms)) + delay_in_seconds = self._clear_ms / 1000 + loop.call_later(delay_in_seconds, clear_cb) + + input_value = request_payload.get("input") + fut_action = asyncio.ensure_future(thing_action.invoke(input_value)) + fut_action.add_done_callback(done_cb) + self._pending_actions[invocation_id] = fut_action + response_payload = json.dumps({"id": invocation_id}).encode("utf-8") + response = aiocoap.Message(code=aiocoap.Code.CREATED, payload=response_payload) + response.opt.content_format = JSON_CONTENT_FORMAT + + return response diff --git a/wotpy/protocols/coap/resources/event.py b/wotpy/protocols/coap/resources/event.py new file mode 100644 index 0000000..4e09de4 --- /dev/null +++ b/wotpy/protocols/coap/resources/event.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +CoAP resources to deal with Event interactions. +""" + +import json +import logging +import time + +import aiocoap +import aiocoap.error +import aiocoap.resource + +from wotpy.protocols.coap.resources.utils import parse_request_opt_query + +JSON_CONTENT_FORMAT = 50 + + +async def get_thing_event(server, request): + """Takes a CoAP request and returns the Thing Event + identified by the request arguments.""" + + query = parse_request_opt_query(request) + url_name_thing = query.get("thing") + url_name_event = query.get("name") + + if not url_name_thing or not url_name_event: + raise aiocoap.error.BadRequest("Missing query arguments") + + exposed_thing = server.exposed_thing_set.find_by_thing_name(url_name_thing) + + if not exposed_thing: + raise aiocoap.error.NotFound("Thing not found") + + valid_creds = await server._check_credentials(exposed_thing.title, request) + if not valid_creds: + raise aiocoap.error.Unauthorized("Authentication required") + + try: + return next( + exposed_thing.events[key] for key in exposed_thing.events + if exposed_thing.events[key].url_name == url_name_event) + except StopIteration: + raise aiocoap.error.NotFound("Event not found") + + +class EventResource(aiocoap.resource.ObservableResource): + """CoAP resource to observe Event emissions.""" + + def __init__(self, server): + super(EventResource, self).__init__() + self._server = server + self._subscription = None + self._last_events = {} + self._logr = logging.getLogger(__name__) + + @classmethod + def _event_key(cls, thing_event): + """Returns the internal event key for the given Thing Event.""" + + return thing_event.thing.url_name, thing_event.url_name + + async def add_observation(self, request, server_observation): + """Method that decides whether to add a new observer. + A new observer is added for each GET request.""" + + if request.code.name != aiocoap.Code.GET.name: + return + + try: + thing_event = await get_thing_event(self._server, request) + except aiocoap.error.Error: + return + + def on_next(item): + event_item = { + "name": item.name, + "data": item.data, + "time": int(time.time() * 1000) + } + + self._last_events[self._event_key(thing_event)] = event_item + server_observation.trigger() + + def on_error(err): + self._logr.warning("Error on subscription to {}: {}".format(thing_event, err)) + + subscription = thing_event.subscribe(on_next=on_next, on_error=on_error) + + def cancellation_cb(): + self._logr.debug("Disposing of subscription to: {}".format(thing_event)) + subscription.dispose() + + server_observation.accept(cancellation_cb) + + async def render_get(self, request): + """Returns a CoAP response with the last observed event emission.""" + + thing_event = await get_thing_event(self._server, request) + last_item = self._last_events.get(self._event_key(thing_event), None) + payload = json.dumps(last_item).encode("utf-8") if last_item else b"" + response = aiocoap.Message(code=aiocoap.Code.CONTENT, payload=payload) + response.opt.content_format = JSON_CONTENT_FORMAT + + return response diff --git a/wotpy/protocols/coap/resources/property.py b/wotpy/protocols/coap/resources/property.py new file mode 100644 index 0000000..413266a --- /dev/null +++ b/wotpy/protocols/coap/resources/property.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +CoAP resources to deal with Property interactions. +""" + +import json +import logging + +import aiocoap +import aiocoap.error +import aiocoap.resource + +from wotpy.protocols.coap.resources.utils import parse_request_opt_query + +JSON_CONTENT_FORMAT = 50 + + +async def _build_property_value_response(thing_property): + """Reads the current property value and builds + the CoAP response containing said value.""" + + value = await thing_property.read() + payload = json.dumps({"value": value}).encode("utf-8") + response = aiocoap.Message(code=aiocoap.Code.CONTENT, payload=payload) + response.opt.content_format = JSON_CONTENT_FORMAT + return response + + +async def get_thing_property(server, request): + """Takes a CoAP request and returns the Thing Property + identified by the request arguments.""" + + query = parse_request_opt_query(request) + url_name_thing = query.get("thing") + url_name_prop = query.get("name") + + if not url_name_thing or not url_name_prop: + raise aiocoap.error.BadRequest("Missing query arguments") + + exposed_thing = server.exposed_thing_set.find_by_thing_name(url_name_thing) + + if not exposed_thing: + raise aiocoap.error.NotFound("Thing not found") + + valid_creds = await server._check_credentials(exposed_thing.title, request) + if not valid_creds: + raise aiocoap.error.Unauthorized("Authentication required") + + try: + return next( + exposed_thing.properties[key] for key in exposed_thing.properties + if exposed_thing.properties[key].url_name == url_name_prop) + except StopIteration: + raise aiocoap.error.NotFound("Property not found") + + +class PropertyResource(aiocoap.resource.ObservableResource): + """CoAP resource that implements the Property read, write and observe verbs.""" + + def __init__(self, server): + super(PropertyResource, self).__init__() + self._server = server + self._logr = logging.getLogger(__name__) + + async def add_observation(self, request, server_observation): + """Method that decides whether to add a new observer. + A new observer is added for each GET request.""" + + if request.code.name != aiocoap.Code.GET.name: + return + + try: + thing_property = await get_thing_property(self._server, request) + except aiocoap.error.Error: + return + + # noinspection PyUnusedLocal + def on_next(item): + server_observation.trigger() + + def on_error(err): + self._logr.warning("Error on subscription to {}: {}".format(thing_property, err)) + + subscription = thing_property.subscribe(on_next=on_next, on_error=on_error) + + def cancellation_cb(): + self._logr.debug("Disposing of subscription to: {}".format(thing_property)) + subscription.dispose() + + server_observation.accept(cancellation_cb) + + async def render_get(self, request): + """Returns a CoAP response with the current property value.""" + + thing_property = await get_thing_property(self._server, request) + response = await _build_property_value_response(thing_property) + return response + + async def render_put(self, request): + """Updates the property with the value retrieved from the CoAP request payload.""" + + thing_property = await get_thing_property(self._server, request) + request_payload = json.loads(request.payload) + + if "value" not in request_payload: + raise aiocoap.error.BadRequest() + + query = parse_request_opt_query(request) + exposed_thing = self._server.exposed_thing_set.find_by_thing_name(query.get("thing")) + + try: + await exposed_thing.handle_write_property( + thing_property.name, + request_payload.get("value")) + except TypeError as ex: + raise aiocoap.error.MethodNotAllowed(str(ex)) + response = aiocoap.Message(code=aiocoap.Code.CHANGED) + + return response diff --git a/wotpy/protocols/coap/resources/utils.py b/wotpy/protocols/coap/resources/utils.py new file mode 100644 index 0000000..ec4f6dc --- /dev/null +++ b/wotpy/protocols/coap/resources/utils.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Utility functions for CoAP resources. +""" + +from urllib import parse + + +def parse_request_opt_query(request): + """Takes a CoAP Request and returns a dict containing + the parsed URI query parameters.""" + + parsed_dict = parse.parse_qs("&".join(request.opt.uri_query)) + return {key: val[0] for key, val in parsed_dict.items() if len(val)} diff --git a/wotpy/protocols/coap/server.py b/wotpy/protocols/coap/server.py new file mode 100644 index 0000000..8a58580 --- /dev/null +++ b/wotpy/protocols/coap/server.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that implements the CoAP server. +""" + +import asyncio +import logging + +import aiocoap +import aiocoap.resource + +from wotpy.codecs.enums import MediaTypes +from wotpy.protocols.coap.authenticator import BaseAuthenticator +from wotpy.protocols.coap.enums import CoAPSchemes +from wotpy.protocols.coap.resources.action import ActionResource +from wotpy.protocols.coap.resources.event import EventResource +from wotpy.protocols.coap.resources.property import PropertyResource +from wotpy.protocols.enums import Protocols, InteractionVerbs +from wotpy.protocols.server import BaseProtocolServer +from wotpy.utils.utils import get_main_ipv4_address +from wotpy.wot.enums import InteractionTypes, SecuritySchemeType +from wotpy.wot.form import Form + + +class CoAPServer(BaseProtocolServer): + """CoAP binding server implementation.""" + + DEFAULT_PORT = 5683 + DEFAULT_SECURITY_SCHEME = {"scheme": SecuritySchemeType.NOSEC} + + def __init__(self, port=DEFAULT_PORT, ssl_context=None, action_clear_ms=None, + security_scheme=DEFAULT_SECURITY_SCHEME): + super(CoAPServer, self).__init__(port=port) + self._server = None + self._server_lock = asyncio.Lock() + self._ssl_context = ssl_context + self._action_clear_ms = action_clear_ms + self._logr = logging.getLogger(__name__) + self._servient = None + self._security_scheme = security_scheme if security_scheme.get("scheme", None) in\ + SecuritySchemeType.list() else self.DEFAULT_SECURITY_SCHEME + + @property + def protocol(self): + """Protocol of this server instance. + A member of the Protocols enum.""" + + return Protocols.COAP + + @property + def scheme(self): + """Returns the URL scheme for this server.""" + + return CoAPSchemes.COAPS if self.is_secure else CoAPSchemes.COAP + + @property + def is_secure(self): + """Returns True if this server is configured to use SSL encryption.""" + + return self._ssl_context is not None + + @property + def action_clear_ms(self): + """Returns the timeout (ms) before completed actions are removed from the server.""" + + return self._action_clear_ms if self._action_clear_ms else ActionResource.DEFAULT_CLEAR_MS + + def _build_forms_property(self, proprty, hostname): + """Builds and returns the CoAP Form instances for the given Property interaction.""" + + href_prop = "{}://{}:{}/property?thing={}&name={}".format( + self.scheme, hostname.rstrip("/").lstrip("/"), self.port, + proprty.thing.url_name, proprty.url_name) + + form_read = Form( + interaction=proprty, + protocol=self.protocol, + href=href_prop, + content_type=MediaTypes.JSON, + op=InteractionVerbs.READ_PROPERTY) + + form_write = Form( + interaction=proprty, + protocol=self.protocol, + href=href_prop, + content_type=MediaTypes.JSON, + op=InteractionVerbs.WRITE_PROPERTY) + + form_observe = Form( + interaction=proprty, + protocol=self.protocol, + href=href_prop, + content_type=MediaTypes.JSON, + op=InteractionVerbs.OBSERVE_PROPERTY) + + return [form_read, form_write, form_observe] + + def _build_forms_action(self, action, hostname): + """Builds and returns the CoAP Form instances for the given Action interaction.""" + + href_invoke = "{}://{}:{}/action?thing={}&name={}".format( + self.scheme, hostname.rstrip("/").lstrip("/"), self.port, + action.thing.url_name, action.url_name) + + form_invoke = Form( + interaction=action, + protocol=self.protocol, + href=href_invoke, + content_type=MediaTypes.JSON, + op=InteractionVerbs.INVOKE_ACTION) + + return [form_invoke] + + def _build_forms_event(self, event, hostname): + """Builds and returns the CoAP Form instances for the given Event interaction.""" + + href = "{}://{}:{}/event?thing={}&name={}".format( + self.scheme, hostname.rstrip("/").lstrip("/"), self.port, + event.thing.url_name, event.url_name) + + form = Form( + interaction=event, + protocol=self.protocol, + href=href, + content_type=MediaTypes.JSON, + op=InteractionVerbs.SUBSCRIBE_EVENT) + + return [form] + + def build_forms(self, hostname, interaction): + """Builds and returns a list with all Form that are + linked to this server for the given Interaction.""" + + intrct_type_map = { + InteractionTypes.PROPERTY: self._build_forms_property, + InteractionTypes.ACTION: self._build_forms_action, + InteractionTypes.EVENT: self._build_forms_event + } + + if interaction.interaction_type not in intrct_type_map: + raise ValueError("Unsupported interaction") + + return intrct_type_map[interaction.interaction_type](interaction, hostname) + + def build_base_url(self, hostname, thing): + """Returns the base URL for the given Thing in the context of this server.""" + + if not self.exposed_thing_set.find_by_thing_name(thing.title): + raise ValueError("Unknown Thing") + + return "{}://{}:{}".format( + self.scheme, + hostname.rstrip("/").lstrip("/"), + self.port) + + def _build_root_site(self): + """Builds and returns the root CoAP Site.""" + + root = aiocoap.resource.Site() + + root.add_resource( + (".well-known", "core"), + aiocoap.resource.WKCResource(root.get_resources_as_linkheader)) + + root.add_resource( + ("property",), + PropertyResource(self)) + + root.add_resource( + ("action",), + ActionResource(self, clear_ms=self._action_clear_ms)) + + root.add_resource( + ("event",), + EventResource(self)) + + return root + + def _get_bind_address(self): + """Returns the bind address for the CoAP server. + By default it will try to bind to all addresses, + although this does not work outside Linux. + When the full-featured UDP6 transport is not available it + will try to guess the main IPv4 address and bind to that.""" + + # TODO: Check if this is needed in this version of aiocoap. + # Keep in mind that "get_default_servertransports" is only intented for internal use. + + # transports = list(aiocoap.defaults.get_default_servertransports()) + + # if not (len(transports) == 1 and transports[0] == "udp6"): + # self._logr.warning("Platform does not support aiocoap udp6 transport: {}".format(transports)) + # return get_main_ipv4_address(), self.port + # else: + # return "::", self.port + + return "::", self.port + + async def _check_credentials(self, exposed_thing_name, request): + """Checks the credentials of a request for a specific thing.""" + + if self._servient: + creds = self._servient.retrieve_credentials(exposed_thing_name) + authenticator = BaseAuthenticator.build(self._security_scheme) + return authenticator.authenticate(creds, request) + else: + #TODO: If the server is created without a servient should it try to check credentials in some other way? + return True + + async def start(self, servient=None): + """Starts the CoAP server.""" + + self._servient = servient + + async with self._server_lock: + if self._server is not None: + return + + root = self._build_root_site() + bind_address = self._get_bind_address() + self._logr.info("Binding CoAP server to: {}".format(bind_address)) + coap_server = await aiocoap.Context.create_server_context( + root, bind=bind_address, _ssl_context=self._ssl_context) + self._server = coap_server + + async def stop(self): + """Stops the CoAP server.""" + + async with self._server_lock: + if self._server is None: + return + + await self._server.shutdown() + self._server = None diff --git a/wotpy/protocols/enums.py b/wotpy/protocols/enums.py new file mode 100644 index 0000000..e25b14a --- /dev/null +++ b/wotpy/protocols/enums.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Enumeration classes related to the various protocol servers. +""" + +from wotpy.utils.enums import EnumListMixin + + +class Protocols(EnumListMixin): + """Enumeration of protocol types.""" + + HTTP = "HTTP" + WEBSOCKETS = "WEBSOCKETS" + COAP = "COAP" + MQTT = "MQTT" + + +class InteractionVerbs(EnumListMixin): + """Interactions have one or more defined interaction verbs for each + interaction pattern. Form Relations allow an interaction to have + separate protocol mechanisms to support different interaction verbs.""" + + READ_PROPERTY = "readproperty" + WRITE_PROPERTY = "writeproperty" + OBSERVE_PROPERTY = "observeproperty" + INVOKE_ACTION = "invokeaction" + SUBSCRIBE_EVENT = "subscribeevent" + UNSUBSCRIBE_EVENT = "unsubscribeevent" diff --git a/wotpy/protocols/exceptions.py b/wotpy/protocols/exceptions.py new file mode 100644 index 0000000..31e9c5f --- /dev/null +++ b/wotpy/protocols/exceptions.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Exceptions raised by the protocol binding implementations. +""" + + +class ProtocolClientException(Exception): + """Base Exceptions raised by clients of the protocol binding implementations.""" + + DEFAULT_MSG = "Protocol client error" + + def __init__(self, *args, **kwargs): + if not (args or kwargs): + args = (self.DEFAULT_MSG,) + + super(ProtocolClientException, self).__init__(*args, **kwargs) + + +class FormNotFoundException(ProtocolClientException): + """Exception raised when a form for a given protocol + binding could not be found in a Thing Description.""" + + DEFAULT_MSG = "Protocol Form not found in TD" + + +class ClientRequestTimeout(ProtocolClientException): + """Exception raised when a protocol client request reaches the timeout.""" + + DEFAULT_MSG = "Timeout in protocol client request" diff --git a/wotpy/protocols/http/__init__.py b/wotpy/protocols/http/__init__.py new file mode 100644 index 0000000..452d5f4 --- /dev/null +++ b/wotpy/protocols/http/__init__.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +HTTP Protocol Binding implementation. + +.. autosummary:: + :toctree: _http + + wotpy.protocols.http.handlers + wotpy.protocols.http.authenticator + wotpy.protocols.http.client + wotpy.protocols.http.credential + wotpy.protocols.http.enums + wotpy.protocols.http.server +""" diff --git a/wotpy/protocols/http/authenticator.py b/wotpy/protocols/http/authenticator.py new file mode 100644 index 0000000..fb84cfd --- /dev/null +++ b/wotpy/protocols/http/authenticator.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Authenticator classes that perform checks on the incoming requests. +""" + +import json +from base64 import b64decode +from abc import ABCMeta, abstractmethod + +from tornado import httpclient + +from wotpy.wot.enums import SecuritySchemeType + +class BaseAuthenticator(metaclass=ABCMeta): + """This is the base authenticator class describing + the authentication interface.""" + + def __init__(self, security_scheme_dict): + self._security_scheme_dict = security_scheme_dict + + @abstractmethod + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + @classmethod + def build(cls, security_scheme_dict): + """Builds an instance of the appropriate subclass for the given SecurityScheme.""" + + klass_map = { + SecuritySchemeType.NOSEC: NoSecurityAuthenticator, + SecuritySchemeType.AUTO: AutoSecurityAuthenticator, + SecuritySchemeType.COMBO: ComboSecurityAuthenticator, + SecuritySchemeType.BASIC: BasicSecurityAuthenticator, + SecuritySchemeType.DIGEST: DigestSecurityAuthenticator, + SecuritySchemeType.APIKEY: APIKeySecurityAuthenticator, + SecuritySchemeType.BEARER: BearerSecurityAuthenticator, + SecuritySchemeType.PSK: PSKSecurityAuthenticator, + SecuritySchemeType.OAUTH2: OAuth2SecurityAuthenticator + } + + scheme_type = security_scheme_dict.get("scheme") + klass = klass_map.get(scheme_type) + + if not klass: + raise ValueError("Unknown scheme: {}".format(scheme_type)) + + return klass(security_scheme_dict) + + +class NoSecurityAuthenticator(BaseAuthenticator): + """Authenticator that allows all requests.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + return True + + +class AutoSecurityAuthenticator(BaseAuthenticator): + """Auto security authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class ComboSecurityAuthenticator(BaseAuthenticator): + """Combinator of security schemes authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class BasicSecurityAuthenticator(BaseAuthenticator): + """Basic username and password authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + server_username = server_creds.get("username", None) + server_password = server_creds.get("password", None) + valid_server_creds = server_username is not None and server_password is not None + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Basic "): + return False + + auth_header = auth_header.replace("Basic ", "") + decoded_string = b64decode(auth_header).decode("ascii") + username, password = decoded_string.split(":", 1) + + creds_match = (server_username == username and server_password == password) + + return valid_server_creds and creds_match + + +class DigestSecurityAuthenticator(BaseAuthenticator): + """Digest authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class APIKeySecurityAuthenticator(BaseAuthenticator): + """API Key authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class BearerSecurityAuthenticator(BaseAuthenticator): + """Bearer token authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + server_token = server_creds.get("token", None) + valid_server_creds = server_token is not None + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return False + + token = auth_header.replace("Bearer ", "") + + creds_match = (server_token == token) + + return valid_server_creds and creds_match + + +class PSKSecurityAuthenticator(BaseAuthenticator): + """Pre shared key authenticator.""" + + def authenticate(self, server_creds, request): + """Checks the credentials of a request.""" + + raise NotImplementedError() + + +class OAuth2SecurityAuthenticator(BaseAuthenticator): + """OAuth2 authenticator.""" + + def __init__(self, security_scheme_dict): + super().__init__(security_scheme_dict) + self._endpoint = security_scheme_dict.get("endpoint", None) + + def authenticate(self, server_creds, request): + """Checks the credentials of a request. Assumes that the endpoint provided + in the constructor receives a token in the body of a POST request and replies + with a dictionary containing the `active` key signifying if the token is + currently active or not.""" + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return False + + token = auth_header.replace("Bearer ", "") + + request = httpclient.HTTPRequest( + self._endpoint, + method="POST", + headers={"content-type": "application/x-www-form-urlencoded"}, + body=f"token={token}", + validate_cert=True + ) + + http_client = httpclient.HTTPClient() + response = http_client.fetch(request) + json_response = json.loads(response.body.decode("utf-8")) + valid_token = json_response["active"] + + http_client.close() + + return valid_token diff --git a/wotpy/protocols/http/client.py b/wotpy/protocols/http/client.py new file mode 100644 index 0000000..87be16a --- /dev/null +++ b/wotpy/protocols/http/client.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that contain the client logic for the HTTP protocol. +""" + +import asyncio +import json +import logging +import time + +import tornado.httpclient +import reactivex +from tornado.simple_httpclient import HTTPTimeoutError + +from wotpy.protocols.client import BaseProtocolClient +from wotpy.protocols.enums import Protocols, InteractionVerbs +from wotpy.protocols.exceptions import FormNotFoundException, ClientRequestTimeout +from wotpy.protocols.http.enums import HTTPSchemes +from wotpy.protocols.utils import is_scheme_form +from wotpy.utils.utils import handle_observer_finalization +from wotpy.wot.events import EmittedEvent, PropertyChangeEmittedEvent, PropertyChangeEventInit +from wotpy.protocols.http.credential import BaseCredential + + +class HTTPClient(BaseProtocolClient): + """Implementation of the protocol client interface for the HTTP protocol.""" + + JSON_HEADERS = {"Content-Type": "application/json"} + DEFAULT_CON_TIMEOUT = 60 + DEFAULT_REQ_TIMEOUT = 60 + + def __init__(self, connect_timeout=DEFAULT_CON_TIMEOUT, request_timeout=DEFAULT_REQ_TIMEOUT): + self._connect_timeout = connect_timeout + self._request_timeout = request_timeout + self._logr = logging.getLogger(__name__) + self._credential = None + super(HTTPClient, self).__init__() + + @classmethod + def pick_http_href(cls, td, forms, op=None): + """Picks the most appropriate HTTP form href from the given list of forms.""" + + def is_op_form(form): + try: + return op is None or op == form.op or op in form.op + except TypeError: + return False + + def find_href(scheme): + try: + return next( + form.href for form in forms + if is_scheme_form(form, td.base, scheme) and is_op_form(form)) + except StopIteration: + return None + + form_https = find_href(HTTPSchemes.HTTPS) + + return form_https if form_https is not None else find_href(HTTPSchemes.HTTP) + + @property + def protocol(self): + """Protocol of this client instance. + A member of the Protocols enum.""" + + return Protocols.HTTP + + @property + def connect_timeout(self): + """Returns the default connection timeout for all HTTP requests.""" + + return self._connect_timeout + + @property + def request_timeout(self): + """Returns the default request timeout for all HTTP requests.""" + + return self._request_timeout + + def is_supported_interaction(self, td, name): + """Returns True if the any of the Forms for the Interaction + with the given name is supported in this Protocol Binding client.""" + + forms = td.get_forms(name) + + forms_http = [ + form for form in forms + if is_scheme_form(form, td.base, HTTPSchemes.list()) + ] + + return len(forms_http) > 0 + + def set_security(self, security_scheme_dict, credentials): + """Sets the security credentials for the given security scheme.""" + + credential = BaseCredential.build(security_scheme_dict, credentials) + self._credential = credential + + def sign_request(self, request): + """Adds the appropriate authorization header to the request + and delegates the addition of the header to the credential class.""" + + if self._credential: + return self._credential.sign(request) + + return request + + async def invoke_action(self, td, name, input_value, timeout=None): + """Invokes an Action on a remote Thing. + Returns a Future.""" + + con_timeout = timeout if timeout else self._connect_timeout + req_timeout = timeout if timeout else self._request_timeout + + now = time.time() + + href = self.pick_http_href(td, td.get_action_forms(name)) + + if href is None: + raise FormNotFoundException() + + body = json.dumps({"input": input_value}) + http_client = tornado.httpclient.AsyncHTTPClient() + + try: + http_request = tornado.httpclient.HTTPRequest( + href, method="POST", + body=body, + headers=self.JSON_HEADERS, + connect_timeout=con_timeout, + request_timeout=req_timeout) + except HTTPTimeoutError: + raise ClientRequestTimeout + + response = await http_client.fetch(self.sign_request(http_request)) + resp_body = json.loads(response.body) + + if resp_body.get("error") is not None: + raise Exception(resp_body.get("error")) + else: + return resp_body.get("result") + + async def write_property(self, td, name, value, timeout=None): + """Updates the value of a Property on a remote Thing. + Returns a Future.""" + + con_timeout = timeout if timeout else self._connect_timeout + req_timeout = timeout if timeout else self._request_timeout + + href = self.pick_http_href(td, td.get_property_forms(name)) + + if href is None: + raise FormNotFoundException() + + http_client = tornado.httpclient.AsyncHTTPClient() + body = json.dumps({"value": value}) + + try: + http_request = tornado.httpclient.HTTPRequest( + href, method="PUT", body=body, + headers=self.JSON_HEADERS, + connect_timeout=con_timeout, + request_timeout=req_timeout) + except HTTPTimeoutError: + raise ClientRequestTimeout + + await http_client.fetch(http_request) + + async def read_property(self, td, name, timeout=None): + """Reads the value of a Property on a remote Thing. + Returns a Future.""" + + con_timeout = timeout if timeout else self._connect_timeout + req_timeout = timeout if timeout else self._request_timeout + + href = self.pick_http_href(td, td.get_property_forms(name)) + + if href is None: + raise FormNotFoundException() + + http_client = tornado.httpclient.AsyncHTTPClient() + + try: + http_request = tornado.httpclient.HTTPRequest( + href, method="GET", + connect_timeout=con_timeout, + request_timeout=req_timeout) + except HTTPTimeoutError: + raise ClientRequestTimeout + + response = await http_client.fetch(self.sign_request(http_request)) + result = json.loads(response.body) + result = result.get("value", result) + + return result + + def on_event(self, td, name): + """Subscribes to an event on a remote Thing. + Returns an Observable.""" + + href = self.pick_http_href(td, td.get_event_forms(name)) + + if href is None: + raise FormNotFoundException() + + def subscribe(observer, scheduler): + """Subscription function to observe events using the HTTP protocol.""" + + state = {"active": True} + + @handle_observer_finalization(observer) + async def callback(): + http_client = tornado.httpclient.AsyncHTTPClient() + http_request = tornado.httpclient.HTTPRequest(href, method="GET") + + while state["active"]: + try: + response = await http_client.fetch(self.sign_request(http_request)) + payload = json.loads(response.body).get("payload") + observer.on_next(EmittedEvent(init=payload, name=name)) + except HTTPTimeoutError: + pass + + def unsubscribe(): + state["active"] = False + + loop = asyncio.get_running_loop() + loop.create_task(callback()) + + return unsubscribe + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_property_change(self, td, name): + """Subscribes to property changes on a remote Thing. + Returns an Observable""" + + href = self.pick_http_href(td, td.get_property_forms(name), op=InteractionVerbs.OBSERVE_PROPERTY) + + if href is None: + raise FormNotFoundException() + + def subscribe(observer, scheduler): + """Subscription function to observe property updates using the HTTP protocol.""" + + state = {"active": True} + + @handle_observer_finalization(observer) + async def callback(): + http_client = tornado.httpclient.AsyncHTTPClient() + http_request = tornado.httpclient.HTTPRequest(href, method="GET") + + while state["active"]: + try: + response = await http_client.fetch(self.sign_request(http_request)) + value = json.loads(response.body) + value = value.get("value", value) + init = PropertyChangeEventInit(name=name, value=value) + observer.on_next(PropertyChangeEmittedEvent(init=init)) + except HTTPTimeoutError: + pass + + def unsubscribe(): + state["active"] = False + + loop = asyncio.get_running_loop() + loop.create_task(callback()) + + return unsubscribe + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_td_change(self, url): + """Subscribes to Thing Description changes on a remote Thing. + Returns an Observable.""" + + raise NotImplementedError diff --git a/wotpy/protocols/http/credential.py b/wotpy/protocols/http/credential.py new file mode 100644 index 0000000..6b22399 --- /dev/null +++ b/wotpy/protocols/http/credential.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Credential classes that add the proper authorization creds to the outgoing requests. +""" + +from base64 import b64encode +from abc import ABCMeta, abstractmethod + +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session + +from wotpy.wot.enums import SecuritySchemeType + + +class BaseCredential(metaclass=ABCMeta): + """This is the base credential class describing + the credential interface.""" + + def __init__(self, security_scheme_dict, security_credentials): + self._security_scheme_dict = security_scheme_dict + self._security_credentials = security_credentials + + @abstractmethod + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + @classmethod + def build(cls, security_scheme_dict, security_credentials): + """Builds an instance of the appropriate subclass for the given SecurityScheme.""" + + klass_map = { + SecuritySchemeType.NOSEC: NoSecurityCredential, + SecuritySchemeType.AUTO: AutoSecurityCredential, + SecuritySchemeType.COMBO: ComboSecurityCredential, + SecuritySchemeType.BASIC: BasicSecurityCredential, + SecuritySchemeType.DIGEST: DigestSecurityCredential, + SecuritySchemeType.APIKEY: APIKeySecurityCredential, + SecuritySchemeType.BEARER: BearerSecurityCredential, + SecuritySchemeType.PSK: PSKSecurityCredential, + SecuritySchemeType.OAUTH2: OAuth2SecurityCredential + } + + scheme_type = security_scheme_dict.get("scheme") + klass = klass_map.get(scheme_type) + + if not klass: + raise ValueError("Unknown scheme: {}".format(scheme_type)) + + return klass(security_scheme_dict, security_credentials) + + +class NoSecurityCredential(BaseCredential): + """Credential that allows all requests.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + return request + + +class AutoSecurityCredential(BaseCredential): + """Auto security credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class ComboSecurityCredential(BaseCredential): + """Combinator of security schemes credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class BasicSecurityCredential(BaseCredential): + """Basic username and password credential.""" + + def __init__(self, security_scheme_dict, security_credentials): + super().__init__(security_scheme_dict, security_credentials) + self._username = security_credentials.get("username", None) + self._password = security_credentials.get("password", None) + + assert self._username is not None and self._password is not None + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + encoded_creds = b64encode(f"{self._username}:{self._password}".encode("ascii")) + encoded_creds_str = encoded_creds.decode("ascii") + auth_header = f"Basic {encoded_creds_str}" + request.headers["Authorization"] = auth_header + + return request + + +class DigestSecurityCredential(BaseCredential): + """Digest credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class APIKeySecurityCredential(BaseCredential): + """API Key credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class BearerSecurityCredential(BaseCredential): + """Bearer token credential.""" + + def __init__(self, security_scheme_dict, security_credentials): + super().__init__(security_scheme_dict, security_credentials) + self._token = security_credentials.get("token", None) + + assert self._token is not None + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + auth_header = f"Bearer {self._token}" + request.headers["Authorization"] = auth_header + + return request + + +class PSKSecurityCredential(BaseCredential): + """Pre shared key credential.""" + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + raise NotImplementedError() + + +class OAuth2SecurityCredential(BaseCredential): + """OAuth2 credential.""" + + def __init__(self, security_scheme_dict, security_credentials): + super().__init__(security_scheme_dict, security_credentials) + + self._flow = security_scheme_dict.get("flow", None) + if self._flow == "client": + self._client_id = security_credentials.get("clientId", None) + self._client_secret = security_credentials.get("clientSecret", None) + + self._token_uri = security_scheme_dict.get("token", None) + self._scopes = security_scheme_dict.get("scopes", None) + + client = BackendApplicationClient(client_id=self._client_id) + oauth = OAuth2Session(client=client, scope=self._scopes) + + self._token = oauth.fetch_token( + token_url=self._token_uri, client_id=self._client_id, client_secret=self._client_secret) + + + def sign(self, request): + """Adds the appropriate authorization header to the request.""" + + auth_header = f"Bearer {self._token['access_token']}" + request.headers["Authorization"] = auth_header + + return request diff --git a/wotpy/protocols/http/enums.py b/wotpy/protocols/http/enums.py new file mode 100644 index 0000000..e9c39c6 --- /dev/null +++ b/wotpy/protocols/http/enums.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Enumeration classes related to the HTTP server. +""" + +from wotpy.utils.enums import EnumListMixin + + +class HTTPSchemes(EnumListMixin): + """Enumeration of HTTP schemes.""" + + HTTP = "http" + HTTPS = "https" diff --git a/wotpy/protocols/http/handlers/__init__.py b/wotpy/protocols/http/handlers/__init__.py new file mode 100644 index 0000000..b3e854c --- /dev/null +++ b/wotpy/protocols/http/handlers/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +HTTP request handlers that implement each of the Interaction verbs. + +.. autosummary:: + :toctree: _handlers + + wotpy.protocols.http.handlers.action + wotpy.protocols.http.handlers.event + wotpy.protocols.http.handlers.property + wotpy.protocols.http.handlers.utils +""" diff --git a/wotpy/protocols/http/handlers/action.py b/wotpy/protocols/http/handlers/action.py new file mode 100644 index 0000000..3ef2149 --- /dev/null +++ b/wotpy/protocols/http/handlers/action.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Request handler for Action interactions. +""" + +from tornado.web import RequestHandler + +import wotpy.protocols.http.handlers.utils as handler_utils + + +# noinspection PyAbstractClass,PyAttributeOutsideInit +class ActionInvokeHandler(RequestHandler): + """Handler for Action invocation requests.""" + + # noinspection PyMethodOverriding + def initialize(self, http_server): + self._server = http_server + + async def post(self, thing_name, name): + """Invokes the action and returns the invocation result.""" + + exposed_thing = handler_utils.get_exposed_thing(self._server, thing_name) + valid_creds = await self._server._check_credentials(exposed_thing.title, self.request) + if not valid_creds: + handler_utils.request_auth(self, self._server.security_scheme, thing_name) + else: + input_value = handler_utils.get_argument(self, "input") + try: + result = await exposed_thing.actions[name].invoke(input_value) + self.write({"result": result}) + except Exception as ex: + self.write({"error": str(ex)}) diff --git a/wotpy/protocols/http/handlers/event.py b/wotpy/protocols/http/handlers/event.py new file mode 100644 index 0000000..3d11a91 --- /dev/null +++ b/wotpy/protocols/http/handlers/event.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Request handler for Event interactions. +""" + +import asyncio +import logging + +from tornado.web import RequestHandler + +import wotpy.protocols.http.handlers.utils as handler_utils + + +# noinspection PyAbstractClass,PyAttributeOutsideInit +class EventObserverHandler(RequestHandler): + """Handler for Event subscription requests.""" + + # noinspection PyMethodOverriding + def initialize(self, http_server): + self._server = http_server + self._logr = logging.getLogger(__name__) + + async def get(self, thing_name, name): + """Subscribes to the given Event and waits for the next emission (HTTP long-polling pattern). + Returns the event emission payload and destroys the subscription afterwards.""" + + exposed_thing = handler_utils.get_exposed_thing(self._server, thing_name) + valid_creds = await self._server._check_credentials(exposed_thing.title, self.request) + if not valid_creds: + handler_utils.request_auth(self, self._server.security_scheme, thing_name) + else: + thing_event = exposed_thing.events[name] + + loop = asyncio.get_running_loop() + future_next = loop.create_future() + + def on_next(item): + not future_next.done() and future_next.set_result(item.data) + + def on_error(err): + self._logr.warning("Error on subscription to {}: {}".format(thing_event, err)) + not future_next.done() and future_next.set_exception(err) + + self.subscription = thing_event.subscribe(on_next=on_next, on_error=on_error) + event_payload = await future_next + self.write({"payload": event_payload}) + + def on_finish(self): + """Destroys the subscription to the observable when the request finishes.""" + + try: + self.subscription.dispose() + except AttributeError: + pass diff --git a/wotpy/protocols/http/handlers/property.py b/wotpy/protocols/http/handlers/property.py new file mode 100644 index 0000000..b1da375 --- /dev/null +++ b/wotpy/protocols/http/handlers/property.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Request handler for Property interactions. +""" + +import asyncio +import logging + +from tornado.web import RequestHandler, HTTPError + +import wotpy.protocols.http.handlers.utils as handler_utils + + +# noinspection PyAbstractClass +class PropertyReadWriteHandler(RequestHandler): + """Handler for Property get/set requests.""" + + # noinspection PyMethodOverriding,PyAttributeOutsideInit + def initialize(self, http_server): + self._server = http_server + + async def get(self, thing_name, name): + """Reads and returns the Property value.""" + + exposed_thing = handler_utils.get_exposed_thing(self._server, thing_name) + valid_creds = await self._server._check_credentials(exposed_thing.title, self.request) + if not valid_creds: + handler_utils.request_auth(self, self._server.security_scheme, thing_name) + else: + value = await exposed_thing.properties[name].read() + self.write({"value": value}) + + async def put(self, thing_name, name): + """Updates the Property value.""" + + exposed_thing = handler_utils.get_exposed_thing(self._server, thing_name) + valid_creds = await self._server._check_credentials(exposed_thing.title, self.request) + if not valid_creds: + handler_utils.request_auth(self, self._server.security_scheme, thing_name) + else: + value = handler_utils.get_argument(self, "value", self.request.body) + try: + await exposed_thing.handle_write_property(name, value) + except TypeError as ex: + raise HTTPError(str(ex)) + + +# noinspection PyAbstractClass,PyAttributeOutsideInit +class PropertyObserverHandler(RequestHandler): + """Handler for Property subscription requests.""" + + # noinspection PyMethodOverriding + def initialize(self, http_server): + self._server = http_server + self._logr = logging.getLogger(__name__) + + async def get(self, thing_name, name): + """Subscribes to Property updates and waits for the next event (HTTP long-polling pattern). + Returns the updated value and destroys the subscription.""" + + exposed_thing = handler_utils.get_exposed_thing(self._server, thing_name) + valid_creds = await self._server._check_credentials(exposed_thing.title, self.request) + if not valid_creds: + handler_utils.request_auth(self, self._server.security_scheme, thing_name) + else: + thing_property = exposed_thing.properties[name] + + loop = asyncio.get_running_loop() + future_next = loop.create_future() + + def on_next(item): + not future_next.done() and future_next.set_result(item.data.value) + + def on_error(err): + self._logr.warning("Error on subscription to {}: {}".format(thing_property, err)) + not future_next.done() and future_next.set_exception(err) + + self.subscription = thing_property.subscribe(on_next=on_next, on_error=on_error) + updated_value = await future_next + self.write({"value": updated_value}) + + def on_finish(self): + """Destroys the subscription to the observable when the request finishes.""" + + try: + self.subscription.dispose() + except AttributeError: + pass diff --git a/wotpy/protocols/http/handlers/utils.py b/wotpy/protocols/http/handlers/utils.py new file mode 100644 index 0000000..9ce865a --- /dev/null +++ b/wotpy/protocols/http/handlers/utils.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Request handler for Property interactions. +""" + +import json + +from tornado.web import HTTPError + +APPLICATION_JSON = "application/json" + + +def get_exposed_thing(server, thing_name): + """Utility function to retrieve an ExposedThing + from the HTTPServer or raise an HTTPError.""" + + try: + return server.get_exposed_thing(thing_name) + except ValueError: + raise HTTPError(log_message="Unknown Thing: {}".format(thing_name)) + + +def get_argument(req_handler, name, default=None): + """Returns an argument extracted from the request. + Interprets the body as JSON if the Content-Type is application/json. + Reverts to the default Tornado get_argument otherwise.""" + + if req_handler.request.headers.get("Content-Type") != APPLICATION_JSON: + return req_handler.get_argument(name, default) + + try: + parsed_body = json.loads(req_handler.request.body) + except Exception as ex: + raise HTTPError(log_message="Error decoding JSON: {}".format(ex)) + + if not isinstance(parsed_body, dict): + raise HTTPError(log_message="Not a JSON object: {}".format(parsed_body)) + + return parsed_body.get(name, default) + +def request_auth(req_handler, scheme, thing_name): + """If authentication fails request authentication from the client with the correct scheme.""" + + req_handler.set_header("WWW-Authenticate", f"{scheme} realm={thing_name}") + req_handler.set_status(401) + req_handler.finish() diff --git a/wotpy/protocols/http/server.py b/wotpy/protocols/http/server.py new file mode 100644 index 0000000..2319552 --- /dev/null +++ b/wotpy/protocols/http/server.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that implements the HTTP server. +""" + +import tornado.httpserver +import tornado.web + +from wotpy.codecs.enums import MediaTypes +from wotpy.protocols.enums import Protocols, InteractionVerbs +from wotpy.protocols.http.authenticator import BaseAuthenticator +from wotpy.protocols.http.enums import HTTPSchemes +from wotpy.protocols.http.handlers.action import ActionInvokeHandler +from wotpy.protocols.http.handlers.event import EventObserverHandler +from wotpy.protocols.http.handlers.property import PropertyObserverHandler, PropertyReadWriteHandler +from wotpy.protocols.server import BaseProtocolServer +from wotpy.wot.enums import InteractionTypes, SecuritySchemeType +from wotpy.wot.form import Form + + +class HTTPServer(BaseProtocolServer): + """HTTP binding server implementation.""" + + DEFAULT_PORT = 8080 + DEFAULT_SECURITY_SCHEME = {"scheme": SecuritySchemeType.NOSEC} + + def __init__(self, port=DEFAULT_PORT, ssl_context=None, action_ttl_secs=300, + security_scheme=DEFAULT_SECURITY_SCHEME): + super(HTTPServer, self).__init__(port=port) + self._server = None + self._servient = None + self._app = self._build_app() + self._ssl_context = ssl_context + self._scheme = HTTPSchemes.HTTPS if ssl_context is not None else HTTPSchemes.HTTP + self._action_ttl_secs = action_ttl_secs + self._pending_actions = {} + self._invocation_check_times = {} + self._security_scheme = security_scheme if security_scheme.get("scheme", None) in\ + SecuritySchemeType.list() else self.DEFAULT_SECURITY_SCHEME + + @property + def protocol(self): + """Protocol of this server instance. + A member of the Protocols enum.""" + + return Protocols.HTTP + + @property + def security_scheme(self): + """Returns the configured security scheme of this server.""" + + return self._security_scheme + + @property + def scheme(self): + """Returns the URL scheme for this server.""" + + return self._scheme + + @property + def app(self): + """Tornado application.""" + + return self._app + + @property + def action_ttl(self): + """Returns the Action invocations Time-To-Live (seconds).""" + + return self._action_ttl_secs + + @property + def pending_actions(self): + """Dict of pending action invocations represented as Futures.""" + + return self._pending_actions + + @property + def invocation_check_times(self): + """Dict that contains the timestamp of the last time an invocation was checked by a client.""" + + return self._invocation_check_times + + async def _check_credentials(self, exposed_thing_name, request): + """Checks the credentials of a request for a specific thing.""" + + if self._servient: + creds = self._servient.retrieve_credentials(exposed_thing_name) + authenticator = BaseAuthenticator.build(self._security_scheme) + return authenticator.authenticate(creds, request) + else: + #TODO: If the server is created without a servient should it try to check credentials in some other way? + return True + + def _build_app(self): + """Builds and returns the Tornado application for the WebSockets server.""" + + return tornado.web.Application([( + r"/(?P[^\/]+)/property/(?P[^\/]+)", + PropertyReadWriteHandler, + {"http_server": self} + ), ( + r"/(?P[^\/]+)/property/(?P[^\/]+)/subscription", + PropertyObserverHandler, + {"http_server": self} + ), ( + r"/(?P[^\/]+)/action/(?P[^\/]+)", + ActionInvokeHandler, + {"http_server": self} + ), ( + r"/(?P[^\/]+)/event/(?P[^\/]+)/subscription", + EventObserverHandler, + {"http_server": self} + )]) + + def _build_forms_property(self, proprty, hostname): + """Builds and returns the HTTP Form instances for the given Property interaction.""" + + href_read_write = "{}://{}:{}/{}/property/{}".format( + self.scheme, hostname.rstrip("/").lstrip("/"), self.port, + proprty.thing.url_name, proprty.url_name) + + form_read_write = Form( + interaction=proprty, + protocol=self.protocol, + href=href_read_write, + content_type=MediaTypes.JSON, + op=[InteractionVerbs.READ_PROPERTY, InteractionVerbs.WRITE_PROPERTY]) + + href_observe = "{}/subscription".format(href_read_write) + + form_observe = Form( + interaction=proprty, + protocol=self.protocol, + href=href_observe, + content_type=MediaTypes.JSON, + op=[InteractionVerbs.OBSERVE_PROPERTY]) + + return [form_read_write, form_observe] + + def _build_forms_action(self, action, hostname): + """Builds and returns the HTTP Form instances for the given Action interaction.""" + + href_invoke = "{}://{}:{}/{}/action/{}".format( + self.scheme, hostname.rstrip("/").lstrip("/"), self.port, + action.thing.url_name, action.url_name) + + form_invoke = Form( + interaction=action, + protocol=self.protocol, + href=href_invoke, + content_type=MediaTypes.JSON, + op=[InteractionVerbs.INVOKE_ACTION]) + + return [form_invoke] + + def _build_forms_event(self, event, hostname): + """Builds and returns the HTTP Form instances for the given Event interaction.""" + + href_observe = "{}://{}:{}/{}/event/{}/subscription".format( + self.scheme, hostname.rstrip("/").lstrip("/"), self.port, + event.thing.url_name, event.url_name) + + form_observe = Form( + interaction=event, + protocol=self.protocol, + href=href_observe, + content_type=MediaTypes.JSON, + op=[InteractionVerbs.SUBSCRIBE_EVENT]) + + return [form_observe] + + def build_forms(self, hostname, interaction): + """Builds and returns a list with all Form that are + linked to this server for the given Interaction.""" + + intrct_type_map = { + InteractionTypes.PROPERTY: self._build_forms_property, + InteractionTypes.ACTION: self._build_forms_action, + InteractionTypes.EVENT: self._build_forms_event + } + + if interaction.interaction_type not in intrct_type_map: + raise ValueError("Unsupported interaction") + + return intrct_type_map[interaction.interaction_type](interaction, hostname) + + def build_base_url(self, hostname, thing): + """Returns the base URL for the given Thing in the context of this server.""" + + if not self.exposed_thing_set.find_by_thing_name(thing.title): + raise ValueError("Unknown Thing") + + return "{}://{}:{}/{}".format( + self.scheme, hostname.rstrip("/").lstrip("/"), + self.port, thing.url_name) + + async def start(self, servient=None): + """Starts the HTTP server.""" + + self._servient = servient + + self._server = tornado.httpserver.HTTPServer(self.app, ssl_options=self._ssl_context) + self._server.listen(self.port) + + async def stop(self): + """Stops the HTTP server.""" + + if not self._server: + return + + self._server.stop() + self._server = None diff --git a/wotpy/protocols/mqtt/__init__.py b/wotpy/protocols/mqtt/__init__.py new file mode 100644 index 0000000..1374e5f --- /dev/null +++ b/wotpy/protocols/mqtt/__init__.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +MQTT Protocol Binding implementation. + +.. autosummary:: + :toctree: _mqtt + + wotpy.protocols.mqtt.handlers + wotpy.protocols.mqtt.client + wotpy.protocols.mqtt.enums + wotpy.protocols.mqtt.runner + wotpy.protocols.mqtt.server +""" + +from wotpy.support import is_mqtt_supported + +if is_mqtt_supported() is False: + raise NotImplementedError("MQTT binding is not supported in this platform") diff --git a/wotpy/protocols/mqtt/client.py b/wotpy/protocols/mqtt/client.py new file mode 100644 index 0000000..bf28289 --- /dev/null +++ b/wotpy/protocols/mqtt/client.py @@ -0,0 +1,741 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that contain the client logic for the MQTT protocol. +""" + +import asyncio +import copy +import datetime +import json +import logging +import pprint +import time +import uuid +from urllib import parse + +import amqtt.client +from amqtt.mqtt.constants import QOS_0, QOS_1, QOS_2 +import reactivex + +from wotpy.protocols.client import BaseProtocolClient +from wotpy.protocols.enums import InteractionVerbs, Protocols +from wotpy.protocols.exceptions import (ClientRequestTimeout, + FormNotFoundException) +from wotpy.protocols.mqtt.enums import MQTTSchemes +from wotpy.protocols.mqtt.handlers.action import ActionMQTTHandler +from wotpy.protocols.mqtt.handlers.property import PropertyMQTTHandler +from wotpy.protocols.refs import ConnRefCounter +from wotpy.protocols.utils import is_scheme_form +from wotpy.utils.utils import handle_observer_finalization +from wotpy.wot.events import (EmittedEvent, PropertyChangeEmittedEvent, + PropertyChangeEventInit) + + +class MQTTClient(BaseProtocolClient): + """Implementation of the protocol client interface for the MQTT protocol.""" + + DELIVER_TERMINATE_LOOP_SLEEP_SECS = 0.1 + SLEEP_SECS_DELIVER_ERR = 1.0 + + DEFAULT_DELIVER_TIMEOUT_SECS = 1 + DEFAULT_MSG_WAIT_TIMEOUT_SECS = 5 + DEFAULT_MSG_TTL_SECS = 15 + DEFAULT_STOP_LOOP_TIMEOUT_SECS = 60 + + # Highly permissive default keep_alive to avoid + # disconnections from broker on high throughput scenarios: + # https://github.com/beerfactory/hbmqtt/issues/119#issuecomment-430398094 + + DEFAULT_CLIENT_CONFIG = { + "keep_alive": 90 + } + + def __init__(self, + deliver_timeout_secs=DEFAULT_DELIVER_TIMEOUT_SECS, + msg_wait_timeout_secs=DEFAULT_MSG_WAIT_TIMEOUT_SECS, + msg_ttl_secs=DEFAULT_MSG_TTL_SECS, + timeout_default=None, + amqtt_config=None, + ca_file=None, + stop_loop_timeout_secs=DEFAULT_STOP_LOOP_TIMEOUT_SECS): + self._deliver_timeout_secs = deliver_timeout_secs + self._msg_wait_timeout_secs = msg_wait_timeout_secs + self._msg_ttl_secs = msg_ttl_secs + self._timeout_default = timeout_default + self._amqtt_config = amqtt_config + self.ca_file = ca_file + self._stop_loop_timeout_secs = stop_loop_timeout_secs + self._lock_client = asyncio.Lock() + self._deliver_stop_events = {} + self._msg_conditions = {} + self._clients = {} + self._messages = {} + self._topics = {} + self._ref_counter = ConnRefCounter() + self._logr = logging.getLogger(__name__) + + def _build_client_config(self): + """Returns the config dict for a new amqtt client instance.""" + + config = copy.copy(self.DEFAULT_CLIENT_CONFIG) + config_arg = self._amqtt_config if self._amqtt_config else {} + config.update(config_arg) + + # The library does not resubscribe when reconnecting. + # We need to handle it manually. + + config.update({"auto_reconnect": False}) + + return config + + async def _new_message(self, broker_url, msg): + """Adds the message to the internal queue and notifies all topic listeners.""" + + assert broker_url in self._msg_conditions, "Unknown broker in conditions" + assert msg.topic in self._msg_conditions[broker_url], "Unknown topic" + + if broker_url not in self._messages: + self._messages[broker_url] = {} + + if msg.topic not in self._messages[broker_url]: + self._messages[broker_url][msg.topic] = [] + + self._messages[broker_url][msg.topic].append({ + "id": uuid.uuid4().hex, + "data": json.loads(msg.data.decode()), + "time": time.time() + }) + + async with self._msg_conditions[broker_url][msg.topic]: + self._msg_conditions[broker_url][msg.topic].notify_all() + + self._clean_messages(broker_url) + + async def _reconnect_client(self, broker_url): + """Reconnects an existing client that has been disconnected.""" + + assert broker_url in self._clients, "Unknown broker" + + self._logr.info("Reconnecting MQTT client: {}".format(broker_url)) + + await self._clients[broker_url].reconnect(cleansession=False) + + topics = self._topics.get(broker_url, set()) + + if not len(topics): + return + + self._logr.info("Resubscribing MQTT client on {} to topics:\n{}".format( + broker_url, pprint.pformat(topics))) + + await self._clients[broker_url].subscribe([(topic, qos) for topic, qos in topics]) + + def _build_deliver(self, broker_url, stop_event): + """Factory for functions to get messages delivered by the broker into the messages queue.""" + + async def reconnect(): + """Sleeps for a while and tries to reconnect and resubscribe afterwards.""" + + try: + self._logr.debug("Sleeping for {} s".format( + self.SLEEP_SECS_DELIVER_ERR)) + await asyncio.sleep(self.SLEEP_SECS_DELIVER_ERR) + await self._reconnect_client(broker_url) + except Exception as ex_reconn: + self._logr.warning("Error reconnecting: {}".format( + ex_reconn), exc_info=True) + + async def deliver(): + """Loop that receives the messages from the broker.""" + + assert broker_url in self._clients + + self._logr.debug( + "Entering message delivery loop: {}".format(broker_url)) + + while not stop_event.is_set(): + try: + msg = await self._clients[broker_url].deliver_message( + timeout=self._deliver_timeout_secs) + except asyncio.TimeoutError: + continue + except Exception as ex: + self._logr.warning( + "Error delivering message: {}".format(ex)) + await reconnect() + continue + + try: + await self._new_message(broker_url, msg) + except Exception as ex: + self._logr.warning( + "Error processing message: {}".format(ex), + exc_info=True) + + self._logr.debug( + "Exiting message delivery loop: {}".format(broker_url)) + + stop_event.clear() + + return deliver + + async def _start_deliver_loop(self, broker_url): + """Starts the message delivery loop in the background.""" + + assert broker_url not in self._deliver_stop_events, "Stop event is already defined" + + stop_event = asyncio.Event() + self._deliver_stop_events[broker_url] = stop_event + deliver_loop_cb = self._build_deliver(broker_url, stop_event) + asyncio.create_task(deliver_loop_cb()) + + async def _stop_deliver_loop(self, broker_url): + """Asks the message delivery loop to stop gracefully.""" + + assert broker_url in self._deliver_stop_events, "Unknown broker" + + assert not self._deliver_stop_events[broker_url].is_set(), \ + "Stop event is already set" + + self._deliver_stop_events[broker_url].set() + + now = time.time() + + def raise_timeout(): + """Checks if enought time has passed to raise a timeout error.""" + + if self._stop_loop_timeout_secs is None: + return + + if (time.time() - now) > self._stop_loop_timeout_secs: + raise asyncio.TimeoutError( + "Timeout waiting for message delivery loop") + + while self._deliver_stop_events[broker_url].is_set(): + raise_timeout() + await asyncio.sleep(self.DELIVER_TERMINATE_LOOP_SLEEP_SECS) + + self._deliver_stop_events.pop(broker_url) + + async def _init_client(self, broker_url, ref_id): + """Initializes and connects a client to the given broker URL.""" + + async with self._lock_client: + self._ref_counter.increase(broker_url, ref_id) + + if broker_url in self._clients: + return + + config = self._build_client_config() + + self._logr.debug("Connecting MQTT client to {} with config: {}".format( + broker_url, pprint.pformat(config))) + + self._clients[broker_url] = amqtt.client.MQTTClient(config=config) + + await self._clients[broker_url].connect(broker_url, cafile=self.ca_file, cleansession=False) + + await self._start_deliver_loop(broker_url) + + async def _disconnect_client(self, broker_url, ref_id): + """Decreases the reference counter for the client on the given broker and cleans + all resources when the client does not have any more references pointing to it.""" + + async with self._lock_client: + self._ref_counter.decrease(broker_url, ref_id) + + if self._ref_counter.has_any(broker_url): + return + + try: + self._logr.debug( + "Stopping message delivery loop: {}".format(broker_url)) + await self._stop_deliver_loop(broker_url) + except Exception as ex: + self._logr.warning( + "Error stopping deliver loop: {}".format(ex), + exc_info=True) + + try: + self._logr.debug( + "Disconnecting MQTT client: {}".format(broker_url)) + await self._clients[broker_url].disconnect() + except Exception as ex: + self._logr.warning( + "Error disconnecting: {}".format(ex), + exc_info=True) + + self._clients.pop(broker_url, None) + self._messages.pop(broker_url, None) + self._msg_conditions.pop(broker_url, None) + self._topics.pop(broker_url, None) + + async def _subscribe(self, broker_url, topic, qos): + """Subscribes to a topic.""" + + async with self._lock_client: + if broker_url not in self._clients: + return + + if broker_url not in self._msg_conditions: + self._msg_conditions[broker_url] = {} + + if topic not in self._msg_conditions[broker_url]: + self._msg_conditions[broker_url][topic] = \ + asyncio.Condition() + + if broker_url not in self._topics: + self._topics[broker_url] = set() + + self._topics[broker_url].add((topic, qos)) + + await self._clients[broker_url].subscribe([(topic, qos)]) + + async def _publish(self, broker_url, topic, payload, qos): + """Publishes a message with the given payload in a topic.""" + + async with self._lock_client: + if broker_url not in self._clients: + return + + await self._clients[broker_url].publish(topic, payload, qos=qos) + + def _topic_messages(self, broker_url, topic, from_time=None, ignore_ids=None): + """Returns a generator that yields the messages in the + delivered messages queue for the given topic.""" + + if broker_url not in self._messages: + return + + if topic not in self._messages[broker_url]: + return + + for msg in self._messages[broker_url][topic]: + is_on_time = from_time is None or msg["time"] >= from_time + is_ignored = ignore_ids is not None and msg["id"] in ignore_ids + + if is_on_time and not is_ignored: + yield msg["id"], msg["data"], msg["time"] + + def _clean_messages(self, broker_url): + """Removes the messages that have expired according to the TTL.""" + + if broker_url not in self._messages: + return + + now = time.time() + + self._messages[broker_url] = { + topic: [ + msg for msg in self._messages[broker_url][topic] + if (now - msg["time"]) < self._msg_ttl_secs + ] for topic in self._messages[broker_url] + } + + def _next_match(self, broker_url, topic, func): + """Returns the first message match in the internal messages queue or None.""" + + return next((item for item in self._topic_messages(broker_url, topic) if func(item)), None) + + async def _wait_condition(self, condition): + """Acquires the lock of the condition and waits on it.""" + async with condition: + await condition.wait() + + async def _wait_on_message(self, broker_url, topic): + """Waits for the arrival of a message in the given topic.""" + + assert broker_url in self._msg_conditions, "Unknown broker URL" + assert topic in self._msg_conditions[broker_url], "Unknown topic" + + try: + await asyncio.wait_for( + self._wait_condition( + self._msg_conditions[broker_url][topic]), + timeout=self._msg_wait_timeout_secs) + except asyncio.TimeoutError: + pass + + @classmethod + def _pick_mqtt_href(cls, td, forms, op=None): + """Picks the most appropriate MQTT form href from the given list of forms.""" + + def is_op_form(form): + try: + return op is None or op == form.op or op in form.op + except TypeError: + return False + + return next(( + form.href for form in forms + if is_scheme_form(form, td.base, MQTTSchemes.MQTT) and is_op_form(form) + ), None) + + @classmethod + def _parse_href(cls, href): + """Takes an MQTT form href and returns + the MQTT broker URL and the topic separately.""" + + parsed_href = parse.urlparse(href) + assert parsed_href.scheme and parsed_href.netloc and parsed_href.path + + return { + "broker_url": "{}://{}".format(parsed_href.scheme, parsed_href.netloc), + "topic": parsed_href.path.lstrip("/").rstrip("/") + } + + @property + def protocol(self): + """Protocol of this client instance. + A member of the Protocols enum.""" + + return Protocols.MQTT + + def is_supported_interaction(self, td, name): + """Returns True if the any of the Forms for the Interaction + with the given name is supported in this Protocol Binding client.""" + + forms = td.get_forms(name) + + forms_mqtt = [ + form for form in forms + if is_scheme_form(form, td.base, MQTTSchemes.list()) + ] + + return len(forms_mqtt) > 0 + + async def invoke_action(self, td, name, input_value, timeout=None, + qos_publish=QOS_2, qos_subscribe=QOS_1): + """Invokes an Action on a remote Thing. + Returns a Future.""" + + timeout = timeout if timeout else self._timeout_default + ref_id = uuid.uuid4().hex + + href = self._pick_mqtt_href(td, td.get_action_forms(name)) + + if href is None: + raise FormNotFoundException() + + parsed_href = self._parse_href(href) + broker_url = parsed_href["broker_url"] + + topic_invoke = parsed_href["topic"] + topic_result = ActionMQTTHandler.to_result_topic(topic_invoke) + + try: + await self._init_client(broker_url, ref_id) + await self._subscribe(broker_url, topic_result, qos_subscribe) + + input_data = { + "id": uuid.uuid4().hex, + "input": input_value + } + + input_payload = json.dumps(input_data).encode() + + await self._publish(broker_url, topic_invoke, input_payload, qos_publish) + + ini = time.time() + + while True: + self._logr.debug( + "Checking invocation topic: {}".format(topic_result)) + + if timeout and (time.time() - ini) > timeout: + self._logr.warning( + "Timeout invoking Action: {}".format(topic_result)) + raise ClientRequestTimeout + + msg_match = self._next_match( + broker_url, topic_result, + lambda item: item[1].get("id") == input_data.get("id")) + + if not msg_match: + await self._wait_on_message(broker_url, topic_result) + continue + + msg_id, msg_data, msg_time = msg_match + + if msg_data.get("error", None) is not None: + raise Exception(msg_data.get("error")) + else: + return msg_data.get("result") + finally: + await self._disconnect_client(broker_url, ref_id) + + async def write_property(self, td, name, value, timeout=None, + qos_publish=QOS_2, qos_subscribe=QOS_1, wait_ack=True): + """Updates the value of a Property on a remote Thing. + Due to the MQTT binding design this coroutine yields as soon as the write message has + been published and will not wait for a custom write handler that yields to another coroutine + Returns a Future.""" + + timeout = timeout if timeout else self._timeout_default + ref_id = uuid.uuid4().hex + + href_write = self._pick_mqtt_href( + td, td.get_property_forms(name), + op=InteractionVerbs.WRITE_PROPERTY) + + if href_write is None: + raise FormNotFoundException() + + parsed_href_write = self._parse_href(href_write) + broker_url = parsed_href_write["broker_url"] + + topic_write = parsed_href_write["topic"] + topic_ack = PropertyMQTTHandler.to_write_ack_topic(topic_write) + + try: + await self._init_client(broker_url, ref_id) + await self._subscribe(broker_url, topic_ack, qos_subscribe) + + write_data = { + "action": "write", + "value": value, + "ack": uuid.uuid4().hex + } + + write_payload = json.dumps(write_data).encode() + + await self._publish(broker_url, topic_write, write_payload, qos_publish) + + if not wait_ack: + return + + ini = time.time() + + while True: + self._logr.debug( + "Checking write ACK topic: {}".format(topic_ack)) + + if timeout and (time.time() - ini) > timeout: + self._logr.warning( + "Timeout writing Property: {}".format(topic_ack)) + raise ClientRequestTimeout + + msg_match = self._next_match( + broker_url, topic_ack, + lambda item: item[1].get("ack") == write_data.get("ack")) + + if msg_match: + break + + await self._wait_on_message(broker_url, topic_ack) + finally: + await self._disconnect_client(broker_url, ref_id) + + async def read_property(self, td, name, timeout=None, + qos_publish=QOS_1, qos_subscribe=QOS_1): + """Reads the value of a Property on a remote Thing. + Returns a Future.""" + + timeout = timeout if timeout else self._timeout_default + ref_id = uuid.uuid4().hex + + forms = td.get_property_forms(name) + + href_read = self._pick_mqtt_href( + td, forms, + op=InteractionVerbs.READ_PROPERTY) + + href_obsv = self._pick_mqtt_href( + td, forms, + op=InteractionVerbs.OBSERVE_PROPERTY) + + if href_read is None or href_obsv is None: + raise FormNotFoundException() + + parsed_href_read = self._parse_href(href_read) + parsed_href_obsv = self._parse_href(href_obsv) + + topic_read = parsed_href_read["topic"] + topic_obsv = parsed_href_obsv["topic"] + + broker_read = parsed_href_read["broker_url"] + broker_obsv = parsed_href_obsv["broker_url"] + + try: + await self._init_client(broker_read, ref_id) + broker_obsv != broker_read and (await self._init_client(broker_obsv, ref_id)) + + await self._subscribe(broker_obsv, topic_obsv, qos_subscribe) + + read_time = time.time() + read_payload = json.dumps({"action": "read"}).encode() + + await self._publish(broker_read, topic_read, read_payload, qos_publish) + + ini = time.time() + + while True: + self._logr.debug( + "Checking property update topic: {}".format(topic_obsv)) + + if timeout and (time.time() - ini) > timeout: + self._logr.warning( + "Timeout reading Property: {}".format(topic_obsv)) + raise ClientRequestTimeout + + msg_match = self._next_match( + broker_obsv, topic_obsv, + lambda item: item[2] >= read_time) + + if not msg_match: + await self._wait_on_message(broker_obsv, topic_obsv) + continue + + msg_id, msg_data, msg_time = msg_match + + return msg_data.get("value") + finally: + await self._disconnect_client(broker_read, ref_id) + broker_obsv != broker_read and (await self._disconnect_client(broker_obsv, ref_id)) + + def _build_subscribe(self, broker_url, topic, next_item_builder, qos): + """Builds the subscribe function that should be passed when + constructing an Observable to listen for messages on an MQTT topic.""" + + def subscribe(observer, scheduler): + """Subscriber function that listens for MQTT messages + on a given topic and passes them to the Observer.""" + + state = {"active": True} + + config = self._build_client_config() + client = amqtt.client.MQTTClient(config=config) + + @handle_observer_finalization(observer) + async def callback(): + self._logr.debug("Subscribing on <{}> to {} with config: {}".format( + broker_url, topic, config)) + + await client.connect(broker_url, cafile=self.ca_file) + await client.subscribe([(topic, qos)]) + + while state["active"]: + try: + msg = await client.deliver_message(timeout=self._deliver_timeout_secs) + except asyncio.TimeoutError: + continue + + try: + msg_data = json.loads(msg.data.decode()) + next_item = next_item_builder(msg_data) + observer.on_next(next_item) + except Exception as ex: + self._logr.warning( + "Subscription message error: {}".format(ex), exc_info=True) + + def unsubscribe(): + """Disconnects from the MQTT broker and stops the message delivering loop.""" + + async def disconnect(): + try: + await client.disconnect() + except Exception as ex: + self._logr.warning( + "Subscription disconnection error: {}".format(ex)) + + asyncio.create_task(disconnect()) + + state["active"] = False + + asyncio.create_task(callback()) + + return unsubscribe + + return subscribe + + def on_property_change(self, td, name, qos=QOS_0): + """Subscribes to property changes on a remote Thing. + Returns an Observable""" + + forms = td.get_property_forms(name) + + href = self._pick_mqtt_href( + td, forms, + op=InteractionVerbs.OBSERVE_PROPERTY) + + if href is None: + raise FormNotFoundException() + + parsed_href = self._parse_href(href) + + broker_url = parsed_href["broker_url"] + topic = parsed_href["topic"] + + def next_item_builder(msg_data): + msg_value = msg_data.get("value") + init = PropertyChangeEventInit(name=name, value=msg_value) + return PropertyChangeEmittedEvent(init=init) + + subscribe = self._build_subscribe( + broker_url=broker_url, + topic=topic, + next_item_builder=next_item_builder, + qos=qos) + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_event(self, td, name, qos=QOS_0): + """Subscribes to an event on a remote Thing. + Returns an Observable.""" + + forms = td.get_event_forms(name) + + href = self._pick_mqtt_href( + td, forms, + op=InteractionVerbs.SUBSCRIBE_EVENT) + + if href is None: + raise FormNotFoundException() + + parsed_href = self._parse_href(href) + + broker_url = parsed_href["broker_url"] + topic = parsed_href["topic"] + + def next_item_builder(msg_data): + return EmittedEvent(init=msg_data.get("data"), name=name) + + subscribe = self._build_subscribe( + broker_url=broker_url, + topic=topic, + next_item_builder=next_item_builder, + qos=qos) + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_td_change(self, url): + """Subscribes to Thing Description changes on a remote Thing. + Returns an Observable.""" + + raise NotImplementedError diff --git a/wotpy/protocols/mqtt/enums.py b/wotpy/protocols/mqtt/enums.py new file mode 100644 index 0000000..b9471db --- /dev/null +++ b/wotpy/protocols/mqtt/enums.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Enumeration classes related to the MQTT protocol binding. +""" + +from wotpy.utils.enums import EnumListMixin + + +class MQTTSchemes(EnumListMixin): + """Enumeration of MQTT schemes.""" + + MQTT = "mqtt" + + +class MQTTCommandCodes(EnumListMixin): + """Enumeration of MQTT packet types.""" + + PUBLISH = 3 + SUBSCRIBE = 8 + UNSUBSCRIBE = 10 + + +class MQTTQoSLevels(EnumListMixin): + """Enumeration of MQTT Quality of Service levels.""" + + FIRE_FORGET = 0 + AT_LEAST_ONCE = 1 + EXACTLY_ONCE = 2 + + +class MQTTVocabularyKeys(EnumListMixin): + """Enumeration of terms that form the MQTT vocabulary that may appear in TD Form elements.""" + + COMMAND_CODE = "mqtt:commandCode" + OPTIONS = "mqtt:options" + OPTION_NAME = "mqtt:optionName" + OPTION_VALUE = "mqtt:optionValue" + OPTION_NAME_QOS = "mqtt:qos" + OPTION_NAME_RETAIN = "mqtt:retain" + OPTION_NAME_DUP = "mqtt:dup" + + +class MQTTCodesACK(EnumListMixin): + """Enumeration of MQTT ACK codes.""" + + CON_OK = 0 + SUB_ERROR = 128 diff --git a/wotpy/protocols/mqtt/handlers/__init__.py b/wotpy/protocols/mqtt/handlers/__init__.py new file mode 100644 index 0000000..54d7425 --- /dev/null +++ b/wotpy/protocols/mqtt/handlers/__init__.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Entities that handle the MQTT operations needed to support each of the Interaction verbs. + +.. autosummary:: + :toctree: _handlers + + wotpy.protocols.mqtt.handlers.action + wotpy.protocols.mqtt.handlers.base + wotpy.protocols.mqtt.handlers.event + wotpy.protocols.mqtt.handlers.ping + wotpy.protocols.mqtt.handlers.property + wotpy.protocols.mqtt.handlers.subs +""" diff --git a/wotpy/protocols/mqtt/handlers/action.py b/wotpy/protocols/mqtt/handlers/action.py new file mode 100644 index 0000000..14ccfca --- /dev/null +++ b/wotpy/protocols/mqtt/handlers/action.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +MQTT handler for Action invocations. +""" + +import json +import time +from json import JSONDecodeError + +from amqtt.mqtt.constants import QOS_2 + +from wotpy.protocols.mqtt.handlers.base import BaseMQTTHandler +from wotpy.utils.utils import to_json_obj + + +class ActionMQTTHandler(BaseMQTTHandler): + """MQTT handler for Action invocations.""" + + KEY_INPUT = "input" + KEY_INVOCATION_ID = "id" + + def __init__(self, mqtt_server, qos=QOS_2): + super(ActionMQTTHandler, self).__init__(mqtt_server) + + self._qos = qos + + @property + def topic_wildcard_invocation(self): + """Wildcard topic to subscribe to all Action invocations.""" + + return "{}/action/invocation/#".format(self.servient_id) + + @classmethod + def to_result_topic(cls, invocation_topic): + """Takes an Action invocation MQTT topic and returns the related result topic.""" + + topic_split = invocation_topic.split("/") + servient_id, thing_name, action_name = topic_split[-5], topic_split[-2], topic_split[-1] + + return "{}/action/result/{}/{}".format( + servient_id, + thing_name, + action_name) + + def build_action_result_topic(self, thing, action): + """Returns the MQTT topic for Action invocation results.""" + + return "{}/action/result/{}/{}".format( + self.servient_id, + thing.url_name, + action.url_name) + + @property + def topics(self): + """List of topics that this MQTT handler wants to subscribe to.""" + + return [(self.topic_wildcard_invocation, self._qos)] + + async def handle_message(self, msg): + """Listens to all Property request topics and responds to read and write requests.""" + + now_ms = int(time.time() * 1000) + + try: + parsed_msg = json.loads(msg.data.decode()) + except (JSONDecodeError, TypeError): + return + + topic_split = msg.topic.split("/") + + splits_expected_len = len(self.topic_wildcard_invocation.split("/")) + 1 + + if len(topic_split) != splits_expected_len: + return + + thing_url_name, action_url_name = topic_split[-2], topic_split[-1] + + try: + exp_thing = next( + item for item in self.mqtt_server.exposed_things + if item.url_name == thing_url_name) + + action = next( + exp_thing.thing.actions[key] for key in exp_thing.thing.actions + if exp_thing.thing.actions[key].url_name == action_url_name) + except StopIteration: + return + + input_value = parsed_msg.get(self.KEY_INPUT, None) + + data = { + "id": parsed_msg.get(self.KEY_INVOCATION_ID, None), + "timestamp": now_ms + } + + try: + result = await exp_thing.actions[action.name].invoke(input_value) + data.update({"result": to_json_obj(result)}) + except Exception as ex: + data.update({"error": str(ex)}) + + topic = self.build_action_result_topic(exp_thing.thing, action) + + await self.queue.put({ + "topic": topic, + "data": json.dumps(data).encode(), + "qos": self._qos + }) diff --git a/wotpy/protocols/mqtt/handlers/base.py b/wotpy/protocols/mqtt/handlers/base.py new file mode 100644 index 0000000..e8e0a63 --- /dev/null +++ b/wotpy/protocols/mqtt/handlers/base.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Base class for all MQTT handlers. +""" + +import asyncio + + +class BaseMQTTHandler: + """Base class for all MQTT handlers.""" + + def __init__(self, mqtt_server): + self._mqtt_server = mqtt_server + self._queue = asyncio.Queue() + + @property + def servient_id(self): + """Servient ID that is used to avoid topic collisions + øwhen multiple Servients are connected to the same broker.""" + + return self._mqtt_server.servient_id + + @property + def mqtt_server(self): + """MQTT server that contains this handler.""" + + return self._mqtt_server + + @property + def topics(self): + """List of topics that this MQTT handler wants to subscribe to.""" + + return None + + @property + def queue(self): + """Asynchronous queue where the handler leaves messages + that should be published later by the runner.""" + + return self._queue + + async def handle_message(self, msg): + """Called each time the runner receives a message for one of the handler topics.""" + + pass + + async def init(self): + """Initializes the MQTT handler. + Called when the MQTT runner starts.""" + + pass + + async def teardown(self): + """Destroys the MQTT handler. + Called when the MQTT runner stops.""" + + pass diff --git a/wotpy/protocols/mqtt/handlers/event.py b/wotpy/protocols/mqtt/handlers/event.py new file mode 100644 index 0000000..87d1a38 --- /dev/null +++ b/wotpy/protocols/mqtt/handlers/event.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +MQTT handler for Event subscriptions. +""" + +import asyncio +import json +import random +import time + +from amqtt.mqtt.constants import QOS_0 + +from wotpy.protocols.mqtt.handlers.base import BaseMQTTHandler +from wotpy.protocols.mqtt.handlers.subs import InteractionsSubscriber +from wotpy.utils.utils import to_json_obj +from wotpy.wot.enums import InteractionTypes + + +class EventMQTTHandler(BaseMQTTHandler): + """MQTT handler for Event subscriptions.""" + + DEFAULT_CALLBACK_MS = 2000 + DEFAULT_JITTER = 0.2 + + def __init__(self, mqtt_server, qos=QOS_0, callback_ms=None): + super(EventMQTTHandler, self).__init__(mqtt_server) + + callback_ms = self.DEFAULT_CALLBACK_MS if callback_ms is None else callback_ms + + self._qos = qos + self._callback_ms = callback_ms + self._subs = {} + self._periodic_refresh_subs = None + + self._interaction_subscriber = InteractionsSubscriber( + interaction_type=InteractionTypes.EVENT, + server=self.mqtt_server, + on_next_builder=self._build_on_next) + + def build_event_topic(self, thing, event): + """Returns the MQTT topic for Event emissions.""" + + return "{}/event/{}/{}".format( + self.servient_id, + thing.url_name, + event.url_name) + + async def init(self): + """Initializes the MQTT handler. + Called when the MQTT runner starts.""" + + async def refresh_subs(): + while True: + self._interaction_subscriber.refresh() + callback_sec = self._callback_ms / 1000 + callback_sec *= 1 + (self.DEFAULT_JITTER * (random.random() - 0.5)) + await asyncio.sleep(callback_sec) + + self._interaction_subscriber.refresh() + self._periodic_refresh_subs = asyncio.create_task(refresh_subs()) + + return None + + async def teardown(self): + """Destroys the MQTT handler. + Called when the MQTT runner stops.""" + + self._periodic_refresh_subs.cancel() + self._interaction_subscriber.dispose() + + return None + + def _build_on_next(self, exp_thing, event): + """Builds the on_next function to use when subscribing to the given Event.""" + + topic = self.build_event_topic(exp_thing, event) + + def on_next(item): + try: + data = { + "name": item.name, + "data": to_json_obj(item.data), + "timestamp": int(time.time() * 1000) + } + + self.queue.put_nowait({ + "topic": topic, + "data": json.dumps(data).encode(), + "qos": self._qos + }) + except asyncio.QueueFull: + pass + + return on_next diff --git a/wotpy/protocols/mqtt/handlers/ping.py b/wotpy/protocols/mqtt/handlers/ping.py new file mode 100644 index 0000000..f5fba2d --- /dev/null +++ b/wotpy/protocols/mqtt/handlers/ping.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +MQTT handler for PING requests published on the MQTT broker. +""" + +from amqtt.mqtt.constants import QOS_1 + +from wotpy.protocols.mqtt.handlers.base import BaseMQTTHandler + + +class PingMQTTHandler(BaseMQTTHandler): + """MQTT handler for PING requests published on the MQTT broker.""" + + def __init__(self, mqtt_server, qos=QOS_1): + super(PingMQTTHandler, self).__init__(mqtt_server) + self._qos = qos + + @property + def topic_ping(self): + """Ping topic.""" + + return "{}/ping".format(self.servient_id) + + @property + def topic_pong(self): + """Pong topic.""" + + return "{}/pong".format(self.servient_id) + + @property + def topics(self): + """List of topics that this MQTT handler wants to subscribe to.""" + + return [(self.topic_ping, self._qos)] + + async def handle_message(self, msg): + """Publishes a message in the PONG topic with the + same payload as the one received in the PING topic.""" + + return await self.queue.put({ + "topic": self.topic_pong, + "data": msg.data, + "qos": self._qos + }) diff --git a/wotpy/protocols/mqtt/handlers/property.py b/wotpy/protocols/mqtt/handlers/property.py new file mode 100644 index 0000000..c9b7c15 --- /dev/null +++ b/wotpy/protocols/mqtt/handlers/property.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +MQTT handler for Property reads, writes and subscriptions to value updates. +""" + +import asyncio +import json +import random +import time +from json import JSONDecodeError + +from amqtt.mqtt.constants import QOS_0, QOS_2 + +from wotpy.protocols.mqtt.handlers.base import BaseMQTTHandler +from wotpy.protocols.mqtt.handlers.subs import InteractionsSubscriber +from wotpy.utils.utils import to_json_obj +from wotpy.wot.enums import InteractionTypes + + +class PropertyMQTTHandler(BaseMQTTHandler): + """MQTT handler for Property reads, writes and subscriptions to value updates.""" + + KEY_ACTION = "action" + KEY_VALUE = "value" + KEY_ACK = "ack" + ACTION_READ = "read" + ACTION_WRITE = "write" + DEFAULT_CALLBACK_MS = 2000 + DEFAULT_JITTER = 0.2 + + def __init__(self, mqtt_server, qos_observe=QOS_0, qos_rw=QOS_2, callback_ms=None): + super(PropertyMQTTHandler, self).__init__(mqtt_server) + + callback_ms = self.DEFAULT_CALLBACK_MS if callback_ms is None else callback_ms + + self._qos_observe = qos_observe + self._qos_rw = qos_rw + self._callback_ms = callback_ms + self._subs = {} + self._periodic_refresh_subs = None + + self._interaction_subscriber = InteractionsSubscriber( + interaction_type=InteractionTypes.PROPERTY, + server=self.mqtt_server, + on_next_builder=self._build_on_next) + + @property + def topic_wildcard_requests(self): + """Wildcard topic to subscribe to all Property requests.""" + + return "{}/property/requests/#".format(self.servient_id) + + def build_property_updates_topic(self, thing, prop): + """Returns the MQTT topic for Property updates.""" + + return "{}/property/updates/{}/{}".format( + self.servient_id, + thing.url_name, + prop.url_name) + + @classmethod + def to_write_ack_topic(cls, requests_topic): + """Takes a Property requests topic and returns the related write ACK topic.""" + + topic_split = requests_topic.split("/") + servient_id, thing_name, prop_name = topic_split[-5], topic_split[-2], topic_split[-1] + + return "{}/property/ack/{}/{}".format( + servient_id, + thing_name, + prop_name) + + @property + def topics(self): + """List of topics that this MQTT handler wants to subscribe to.""" + + return [(self.topic_wildcard_requests, self._qos_rw)] + + async def handle_message(self, msg): + """Listens to all Property request topics and responds to read and write requests.""" + + try: + parsed_msg = json.loads(msg.data.decode()) + except (JSONDecodeError, TypeError): + return + + action = parsed_msg.get(self.KEY_ACTION, False) + + if not action or action not in [self.ACTION_WRITE, self.ACTION_READ]: + return + + topic_split = msg.topic.split("/") + + splits_expected_len = len(self.topic_wildcard_requests.split("/")) + 1 + + if len(topic_split) != splits_expected_len: + return + + thing_url_name, prop_url_name = topic_split[-2], topic_split[-1] + + try: + exp_thing = next( + item for item in self.mqtt_server.exposed_things + if item.url_name == thing_url_name) + + prop = next( + exp_thing.thing.properties[key] for key in exp_thing.thing.properties + if exp_thing.thing.properties[key].url_name == prop_url_name) + except StopIteration: + return + + if action == self.ACTION_READ: + value = await exp_thing.properties[prop.name].read() + topic = self.build_property_updates_topic(exp_thing.thing, prop) + update_msg = self._build_update_message(topic, value) + await self.queue.put(update_msg) + elif action == self.ACTION_WRITE and self.KEY_VALUE in parsed_msg: + await exp_thing.handle_write_property(prop.name, parsed_msg[self.KEY_VALUE]) + await self.publish_write_ack(msg) + + async def publish_write_ack(self, msg): + """Takes a Property write request message and publishes the related write ACK message.""" + + try: + parsed_msg = json.loads(msg.data.decode()) + except (JSONDecodeError, TypeError): + return + + action = parsed_msg.get(self.KEY_ACTION, None) + ack_code = parsed_msg.get(self.KEY_ACK, None) + + if not action or not ack_code or action != self.ACTION_WRITE: + return + + topic_ack = self.to_write_ack_topic(msg.topic) + + await self.queue.put({ + "topic": topic_ack, + "data": json.dumps({self.KEY_ACK: ack_code}).encode(), + "qos": self._qos_rw + }) + + async def init(self): + """Initializes the MQTT handler. + Called when the MQTT runner starts.""" + + async def refresh_subs(): + while True: + self._interaction_subscriber.refresh() + callback_sec = self._callback_ms / 1000 + callback_sec *= 1 + (self.DEFAULT_JITTER * (random.random() - 0.5)) + await asyncio.sleep(callback_sec) + + self._interaction_subscriber.refresh() + self._periodic_refresh_subs = asyncio.create_task(refresh_subs()) + + return None + + async def teardown(self): + """Destroys the MQTT handler. + Called when the MQTT runner stops.""" + + self._periodic_refresh_subs.cancel() + self._interaction_subscriber.dispose() + + return None + + def _build_update_message(self, topic, value): + """Builds an MQTT message to publish an update for a Property value.""" + + now_ms = int(time.time() * 1000) + + return { + "topic": topic, + "data": json.dumps({ + "value": to_json_obj(value), + "timestamp": now_ms + }).encode(), + "qos": self._qos_observe + } + + def _build_on_next(self, exp_thing, prop): + """Builds the on_next function to use when subscribing to the given Property.""" + + topic = self.build_property_updates_topic(exp_thing, prop) + + def on_next(item): + try: + msg = self._build_update_message(topic, item.data.value) + self.queue.put_nowait(msg) + except asyncio.QueueFull: + pass + + return on_next diff --git a/wotpy/protocols/mqtt/handlers/subs.py b/wotpy/protocols/mqtt/handlers/subs.py new file mode 100644 index 0000000..5c3d98c --- /dev/null +++ b/wotpy/protocols/mqtt/handlers/subs.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that subscribes to all the Interactions of one kind for +all the ExposedThings contained by a Protocol Binding server. +""" + +import logging + +from wotpy.wot.enums import InteractionTypes + + +class InteractionsSubscriber: + """Class that subscribes to all the Interactions of one kind for + all the ExposedThings contained by a Protocol Binding server.""" + + def __init__(self, interaction_type, server, on_next_builder): + assert interaction_type in [InteractionTypes.PROPERTY, InteractionTypes.EVENT] + self._interaction_type = interaction_type + self._server = server + self._on_next_builder = on_next_builder + self._subs = {} + self._logr = logging.getLogger(__name__) + + def _dispose_exposed_thing_subs(self, exp_thing): + """Disposes of all currently active subscriptions for the given ExposedThing.""" + + if exp_thing not in self._subs: + return + + for key in self._subs[exp_thing]: + self._subs[exp_thing][key].dispose() + + self._subs.pop(exp_thing) + + def _interaction_attr_name(self): + """Returns the attribute name of the Thing and ExposedThing + iterator for the current type of interactions.""" + + return { + InteractionTypes.PROPERTY: "properties", + InteractionTypes.EVENT: "events" + }.get(self._interaction_type) + + def _get_exposed_thing_interaction_set(self, exp_thing): + """Returns the set of interactions that should be observed.""" + + attr = self._interaction_attr_name() + + intrc_expected = set(exp_thing.thing.__getattribute__(attr).values()) + + if self._interaction_type == InteractionTypes.PROPERTY: + intrc_expected = set(item for item in intrc_expected if item.observable) + + return intrc_expected + + def _refresh_exposed_thing_subs(self, exp_thing): + """Refresh the subscriptions for the given ExposedThing.""" + + if exp_thing not in self._subs: + self._subs[exp_thing] = {} + + thing_subs = self._subs[exp_thing] + + intrc_expected = self._get_exposed_thing_interaction_set(exp_thing) + intrc_current = set(thing_subs.keys()) + intrc_remove = intrc_current.difference(intrc_expected) + + for intrc in intrc_remove: + thing_subs[intrc].dispose() + thing_subs.pop(intrc) + + intrc_new = [item for item in intrc_expected if item not in thing_subs] + + attr = self._interaction_attr_name() + + for intrc in intrc_new: + on_next = self._on_next_builder(exp_thing, intrc) + exp_thing_intrc = exp_thing.__getattribute__(attr)[intrc.name] + + def on_error(err): + self._logr.warning("Error on subscription to {}: {}".format(exp_thing_intrc, err)) + thing_subs[intrc].dispose() + thing_subs.pop(intrc) + + thing_subs[intrc] = exp_thing_intrc.subscribe(on_next=on_next, on_error=on_error) + + def dispose(self): + """Disposes of all the currently active subscriptions.""" + + for exp_thing in list(self._subs.keys()): + self._dispose_exposed_thing_subs(exp_thing) + + def refresh(self): + """Refresh all subscriptions for the entire set of ExposedThings.""" + + things_expected = set(self._server.exposed_things) + things_current = set(self._subs.keys()) + things_remove = things_current.difference(things_expected) + + for exp_thing in things_remove: + self._dispose_exposed_thing_subs(exp_thing) + + for exp_thing in things_expected: + self._refresh_exposed_thing_subs(exp_thing) diff --git a/wotpy/protocols/mqtt/runner.py b/wotpy/protocols/mqtt/runner.py new file mode 100644 index 0000000..0543a0a --- /dev/null +++ b/wotpy/protocols/mqtt/runner.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Base class for MQTT handlers. +""" + +import asyncio +import copy +import datetime +import logging +import uuid + +from amqtt.client import MQTTClient, ConnectException + +from wotpy.protocols.mqtt.enums import MQTTCodesACK + + +class MQTTHandlerRunner: + """Class that wraps an MQTT handler. It handles connections to the + MQTT broker, delivers messages, and runs the handler in a loop.""" + + DEFAULT_TIMEOUT_LOOPS_SECS = 0.1 + DEFAULT_SLEEP_ERR_RECONN = 2.0 + DEFAULT_MSGS_BUF_SIZE = 500 + + # Highly permissive default keep_alive to avoid + # disconnections from broker on high throughput scenarios: + # https://github.com/beerfactory/hbmqtt/issues/119#issuecomment-430398094 + + DEFAULT_CLIENT_CONFIG = { + "keep_alive": 90 + } + + def __init__(self, broker_url, mqtt_handler, + messages_buffer_size=DEFAULT_MSGS_BUF_SIZE, + timeout_loops=DEFAULT_TIMEOUT_LOOPS_SECS, + sleep_error_reconnect=DEFAULT_SLEEP_ERR_RECONN, + ca_file=None, + amqtt_config=None): + self._broker_url = broker_url + self._mqtt_handler = mqtt_handler + self._messages_buffer = asyncio.Queue(maxsize=messages_buffer_size) + self._timeout_loops_secs = timeout_loops + self._sleep_error_reconnect = sleep_error_reconnect + self._ca_file = ca_file + self._amqtt_config = amqtt_config + self._client = None + self._client_id = uuid.uuid4().hex + self._lock_conn = asyncio.Lock() + self._lock_run = asyncio.Lock() + self._event_stop_request = asyncio.Event() + self._logr = logging.getLogger(__name__) + + def _log(self, level, msg, **kwargs): + """Helper function to wrap all log messages.""" + + self._logr.log(level, "{} - {}".format(self._mqtt_handler.__class__.__name__, msg), **kwargs) + + def _build_client_config(self): + """Returns the config dict for a new amqtt client instance.""" + + config = copy.copy(self.DEFAULT_CLIENT_CONFIG) + config_arg = self._amqtt_config if self._amqtt_config else {} + config.update(config_arg) + + # The library does not resubscribe when reconnecting. + # We need to handle it manually. + + config.update({"auto_reconnect": False}) + + return config + + async def _connect(self): + """MQTT connection helper function.""" + + config = self._build_client_config() + + self._log(logging.DEBUG, "MQTT client ID: {}".format(self._client_id)) + self._log(logging.DEBUG, "MQTT client config: {}".format(config)) + + amqtt_client = MQTTClient(client_id=self._client_id, config=config) + + self._log(logging.INFO, "Connecting MQTT client to broker: {}".format(self._broker_url)) + + ack_con = await amqtt_client.connect(self._broker_url, cafile=self._ca_file, cleansession=False) + + if ack_con != MQTTCodesACK.CON_OK: + raise ConnectException("Error code in connection ACK: {}".format(ack_con)) + + if self._mqtt_handler.topics: + self._log(logging.DEBUG, "Subscribing to: {}".format(self._mqtt_handler.topics)) + ack_sub = await amqtt_client.subscribe(self._mqtt_handler.topics) + + if MQTTCodesACK.SUB_ERROR in ack_sub: + raise ConnectException("Error code in subscription ACK: {}".format(ack_sub)) + + self._client = amqtt_client + + async def _disconnect(self): + """MQTT disconnection helper function.""" + + try: + self._log(logging.DEBUG, "Disconnecting MQTT client") + + if self._mqtt_handler.topics: + self._log(logging.DEBUG, "Unsubscribing from: {}".format(self._mqtt_handler.topics)) + await self._client.unsubscribe([name for name, qos in self._mqtt_handler.topics]) + + await self._client.disconnect() + except Exception as ex: + self._log(logging.DEBUG, "Error disconnecting MQTT client: {}".format(ex), exc_info=True) + finally: + self._client = None + + async def connect(self, force_reconnect=False): + """Connects to the MQTT broker.""" + async with self._lock_conn: + if self._client is not None and force_reconnect: + self._log(logging.DEBUG, "Forcing reconnection") + await self._disconnect() + elif self._client is not None: + return + + await self._connect() + + async def disconnect(self): + """Disconnects from the MQTT broker.""" + + async with self._lock_conn: + if self._client is None: + return + + await self._disconnect() + + async def _deliver_messages(self): + """Receives messages from the MQTT broker and puts them in the internal buffer.""" + + message = None + + while not self._event_stop_request.is_set(): + if message is None: + try: + message = await self._client.deliver_message(timeout=self._timeout_loops_secs) + except asyncio.TimeoutError: + pass + except Exception as ex: + self._log(logging.WARNING, "Error on MQTT deliver: {}".format(ex)) + + try: + await asyncio.sleep(self._sleep_error_reconnect) + await self.connect(force_reconnect=True) + except Exception as ex: + self._log(logging.ERROR, "Error reconnecting: {}".format(ex), exc_info=True) + + if message is not None: + try: + await asyncio.wait_for( + self._messages_buffer.put(message), timeout=self._timeout_loops_secs) + message = None + except asyncio.TimeoutError: + self._log(logging.DEBUG, "Full messages buffer") + + async def _handle_messages(self): + """Gets messages from the internal buffer and + passes them to the MQTT handler to be processed.""" + + while not self._event_stop_request.is_set(): + try: + message = await asyncio.wait_for( + self._messages_buffer.get(), timeout=self._timeout_loops_secs) + self._log(logging.DEBUG, "Handling message: {}".format(message.data)) + asyncio.ensure_future(self._mqtt_handler.handle_message(message)) + except asyncio.TimeoutError: + pass + except Exception as ex: + self._log(logging.WARNING, "MQTT handler error: {}".format(ex), exc_info=True) + + async def _publish_queued_messages(self): + """Gets the pending messages from the handler queue and publishes them on the broker.""" + + message = None + + while not self._event_stop_request.is_set(): + try: + if message is None: + message = await asyncio.wait_for( + self._mqtt_handler.queue.get(), timeout=self._timeout_loops_secs) + else: + self._log(logging.WARNING, "Republish attempt: {}".format(message)) + + await self._client.publish( + topic=message["topic"], + message=message["data"], + qos=message.get("qos", None), + retain=message.get("retain", None)) + + message = None + except asyncio.TimeoutError: + pass + except Exception as ex: + self._log(logging.WARNING, "Exception publishing: {}".format(ex), exc_info=True) + await asyncio.sleep(self._sleep_error_reconnect) + + def _add_loop_callback(self): + """Adds the callback that will start the infinite loop + to listen and handle the messages published in the topics + that are of interest to this MQTT client.""" + + async def run_loop(): + try: + async with self._lock_run: + self._log(logging.DEBUG, "Entering MQTT runner loop") + + asyncio.ensure_future(self._deliver_messages()) + asyncio.ensure_future(self._handle_messages()) + asyncio.ensure_future(self._publish_queued_messages()) + except asyncio.TimeoutError: + self._log(logging.WARNING, "Cannot start MQTT handler loop while another is already running") + + asyncio.create_task(run_loop()) + + async def start(self): + """Starts listening for published messages.""" + + self._event_stop_request.set() + + async with self._lock_run: + self._event_stop_request.clear() + + await self.connect(force_reconnect=True) + + await self._mqtt_handler.init() + + self._add_loop_callback() + + async def stop(self): + """Stops listening for published messages.""" + + self._event_stop_request.set() + + async with self._lock_run: + pass + + await self._mqtt_handler.teardown() + + await self.disconnect() diff --git a/wotpy/protocols/mqtt/server.py b/wotpy/protocols/mqtt/server.py new file mode 100644 index 0000000..3d8e2a4 --- /dev/null +++ b/wotpy/protocols/mqtt/server.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that implements the MQTT server (broker). +""" + +import asyncio + +from slugify import slugify + +from wotpy.codecs.enums import MediaTypes +from wotpy.protocols.enums import Protocols, InteractionVerbs +from wotpy.protocols.mqtt.handlers.action import ActionMQTTHandler +from wotpy.protocols.mqtt.handlers.event import EventMQTTHandler +from wotpy.protocols.mqtt.handlers.ping import PingMQTTHandler +from wotpy.protocols.mqtt.handlers.property import PropertyMQTTHandler +from wotpy.protocols.mqtt.runner import MQTTHandlerRunner +from wotpy.protocols.server import BaseProtocolServer +from wotpy.wot.enums import InteractionTypes +from wotpy.wot.form import Form + + +class MQTTServer(BaseProtocolServer): + """MQTT binding server implementation.""" + + DEFAULT_SERVIENT_ID = 'wotpy' + + def __init__(self, broker_url, property_callback_ms=None, event_callback_ms=None, + ca_file=None, servient_id=None): + super(MQTTServer, self).__init__(port=None) + self._broker_url = broker_url + self._ca_file = ca_file + self._server_lock = asyncio.Lock() + self._servient_id = servient_id + self._servient = None + + def build_runner(handler): + return MQTTHandlerRunner(broker_url=self._broker_url, mqtt_handler=handler, ca_file=self._ca_file) + + self._handler_runners = [ + build_runner(PingMQTTHandler(mqtt_server=self)), + build_runner(PropertyMQTTHandler(mqtt_server=self, callback_ms=property_callback_ms)), + build_runner(EventMQTTHandler(mqtt_server=self, callback_ms=event_callback_ms)), + build_runner(ActionMQTTHandler(mqtt_server=self)), + ] + + @property + def servient_id(self): + """Servient ID that is used to avoid topic collisions + when multiple Servients are connected to the same broker.""" + + return slugify(self._servient_id) if self._servient_id else self.DEFAULT_SERVIENT_ID + + @property + def protocol(self): + """Protocol of this server instance. + A member of the Protocols enum.""" + + return Protocols.MQTT + + def _build_forms_property(self, proprty): + """Builds and returns the MQTT Form instances for the given Property interaction.""" + + href_rw = "{}/{}/property/requests/{}/{}".format( + self._broker_url.rstrip("/"), + self.servient_id, + proprty.thing.url_name, + proprty.url_name) + + form_read = Form( + interaction=proprty, + protocol=self.protocol, + href=href_rw, + content_type=MediaTypes.JSON, + op=InteractionVerbs.READ_PROPERTY) + + form_write = Form( + interaction=proprty, + protocol=self.protocol, + href=href_rw, + content_type=MediaTypes.JSON, + op=InteractionVerbs.WRITE_PROPERTY) + + href_observe = "{}/{}/property/updates/{}/{}".format( + self._broker_url.rstrip("/"), + self.servient_id, + proprty.thing.url_name, + proprty.url_name) + + form_observe = Form( + interaction=proprty, + protocol=self.protocol, + href=href_observe, + content_type=MediaTypes.JSON, + op=InteractionVerbs.OBSERVE_PROPERTY) + + return [form_read, form_write, form_observe] + + def _build_forms_action(self, action): + """Builds and returns the MQTT Form instances for the given Action interaction.""" + + href = "{}/{}/action/invocation/{}/{}".format( + self._broker_url.rstrip("/"), + self.servient_id, + action.thing.url_name, + action.url_name) + + form = Form( + interaction=action, + protocol=self.protocol, + href=href, + content_type=MediaTypes.JSON, + op=InteractionVerbs.INVOKE_ACTION) + + return [form] + + def _build_forms_event(self, event): + """Builds and returns the MQTT Form instances for the given Event interaction.""" + + href = "{}/{}/event/{}/{}".format( + self._broker_url.rstrip("/"), + self.servient_id, + event.thing.url_name, + event.url_name) + + form = Form( + interaction=event, + protocol=self.protocol, + href=href, + content_type=MediaTypes.JSON, + op=InteractionVerbs.SUBSCRIBE_EVENT) + + return [form] + + def build_forms(self, hostname, interaction): + """Builds and returns a list with all Forms that are + linked to this server for the given Interaction.""" + + intrct_type_map = { + InteractionTypes.PROPERTY: self._build_forms_property, + InteractionTypes.ACTION: self._build_forms_action, + InteractionTypes.EVENT: self._build_forms_event + } + + if interaction.interaction_type not in intrct_type_map: + raise ValueError("Unsupported interaction") + + return intrct_type_map[interaction.interaction_type](interaction) + + def build_base_url(self, hostname, thing): + """Returns the base URL for the given Thing in the context of this server.""" + + return self._broker_url + + async def start(self, servient=None): + """Starts the MQTT broker and all the MQTT clients + that handle the WoT clients requests.""" + + self._servient = servient + + async with self._server_lock: + for runner in self._handler_runners: + await runner.start() + + async def stop(self): + """Stops the MQTT broker and the MQTT clients.""" + + async with self._server_lock: + for runner in self._handler_runners: + await runner.stop() diff --git a/wotpy/protocols/refs.py b/wotpy/protocols/refs.py new file mode 100644 index 0000000..77f7de8 --- /dev/null +++ b/wotpy/protocols/refs.py @@ -0,0 +1,62 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import logging + + +class ConnRefCounter: + """A simple connection reference counter to keep + track of active connections and enable reuse.""" + + def __init__(self): + self._counter = {} + self._logr = logging.getLogger(__name__) + + def increase(self, conn_id, ref_id): + """Increases the reference counter for the connection.""" + + if conn_id not in self._counter: + self._counter[conn_id] = set() + + self._counter[conn_id].add(ref_id) + + self._logr.debug("Added ref {} to conn <{}> (current: {})".format( + ref_id, conn_id, len(self._counter[conn_id]))) + + def decrease(self, conn_id, ref_id): + """Decreases the reference counter for the connection.""" + + if conn_id not in self._counter: + self._logr.warning("Attempted to decrease ref of unknown conn: {}".format(conn_id)) + return + + try: + self._counter[conn_id].remove(ref_id) + + self._logr.debug("Removed ref {} from conn <{}> (current: {})".format( + ref_id, conn_id, len(self._counter[conn_id]))) + except KeyError: + self._logr.warning("Attempted to remove unknown reference: {}".format(ref_id)) + + def has_any(self, conn_id): + """Returns True if the connection has any references pointing to it.""" + + return conn_id in self._counter and len(self._counter[conn_id]) diff --git a/wotpy/protocols/server.py b/wotpy/protocols/server.py new file mode 100644 index 0000000..608963e --- /dev/null +++ b/wotpy/protocols/server.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents the abstract server interface. +""" + +from abc import ABCMeta, abstractmethod + +from wotpy.wot.exposed.thing_set import ExposedThingSet + + +class BaseProtocolServer(metaclass=ABCMeta): + """Base protocol server class. + This is the interface that must be implemented by all server classes.""" + + def __init__(self, port): + self._port = port + self._codecs = [] + self._exposed_thing_set = ExposedThingSet() + + @property + @abstractmethod + def protocol(self): + """Server protocol.""" + + raise NotImplementedError() + + @property + def port(self): + """Port property.""" + + return self._port + + @property + def exposed_thing_set(self): + """Returns the ExposedThingSet instance that + contains the ExposedThings of this server.""" + + return self._exposed_thing_set + + @property + def exposed_things(self): + """Returns an iterator for all the ExposedThings contained in this server.""" + + return self._exposed_thing_set.exposed_things + + def codec_for_media_type(self, media_type): + """Returns a BaseCodec to serialize or deserialize content for the given media type.""" + + try: + return next(codec for codec in self._codecs if media_type in codec.media_types) + except StopIteration: + raise ValueError('Unknown media type') + + def add_codec(self, codec): + """Adds a BaseCodec to this server.""" + + self._codecs.append(codec) + + def add_exposed_thing(self, exposed_thing): + """Adds the given ExposedThing to this server.""" + + self._exposed_thing_set.add(exposed_thing) + + def remove_exposed_thing(self, thing_name): + """Removes the given ExposedThing from this server.""" + + self._exposed_thing_set.remove(thing_name) + + def get_exposed_thing(self, name): + """Finds and returns an ExposedThing contained in this server by name. + Raises ValueError if the ExposedThing is not present.""" + + exposed_thing = self._exposed_thing_set.find_by_thing_name(name) + + if exposed_thing is None: + raise ValueError("Unknown Exposed Thing: {}".format(name)) + + return exposed_thing + + @abstractmethod + def build_forms(self, hostname, interaction): + """Builds and returns a list with all Form that are + linked to this server for the given Interaction.""" + + raise NotImplementedError() + + @abstractmethod + def build_base_url(self, hostname, thing): + """Returns the base URL for the given Thing in the context of this server.""" + + raise NotImplementedError() + + @abstractmethod + async def start(self, servient): + """Coroutine that starts the server.""" + + raise NotImplementedError() + + @abstractmethod + async def stop(self): + """Coroutine that stops the server. + Some requests could be still in progress and would be served after the server has stopped.""" + + raise NotImplementedError() diff --git a/wotpy/protocols/utils.py b/wotpy/protocols/utils.py new file mode 100644 index 0000000..98dd062 --- /dev/null +++ b/wotpy/protocols/utils.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Utility functions used by client and server implementations. +""" + +import urllib + + +def is_scheme_form(form, base, scheme): + """Returns True if the scheme of the URI for + the given Form matches the scheme argument.""" + + resolved_url = form.resolve_uri(base=base) + + if not resolved_url: + return False + + parsed_scheme = urllib.parse.urlparse(resolved_url).scheme + + return parsed_scheme in scheme if isinstance(scheme, list) else parsed_scheme == scheme + + +def pick_form(td, forms, schemes, op=None): + """Picks the Form that will be used to connect to the remote Thing.""" + + for scheme in schemes: + scheme_forms = [ + form for form in forms + if is_scheme_form(form, td.base, scheme) + ] + + if op is not None: + scheme_forms = [form for form in scheme_forms if form.op == op] + + if len(scheme_forms): + return scheme_forms[0] + + return None diff --git a/wotpy/protocols/ws/__init__.py b/wotpy/protocols/ws/__init__.py new file mode 100644 index 0000000..536ebb6 --- /dev/null +++ b/wotpy/protocols/ws/__init__.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +WebSockets Protocol Binding implementation. + +.. autosummary:: + :toctree: _ws + + wotpy.protocols.ws.client + wotpy.protocols.ws.enums + wotpy.protocols.ws.handler + wotpy.protocols.ws.messages + wotpy.protocols.ws.schemas + wotpy.protocols.ws.server +""" diff --git a/wotpy/protocols/ws/client.py b/wotpy/protocols/ws/client.py new file mode 100644 index 0000000..cd5dd7a --- /dev/null +++ b/wotpy/protocols/ws/client.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that contain the client logic for the Websocket protocol. +""" + +import asyncio +import datetime +import logging +import uuid + +import tornado.websocket +import reactivex + +from wotpy.protocols.client import BaseProtocolClient +from wotpy.protocols.enums import Protocols +from wotpy.protocols.exceptions import FormNotFoundException, ClientRequestTimeout +from wotpy.protocols.refs import ConnRefCounter +from wotpy.protocols.utils import pick_form, is_scheme_form +from wotpy.protocols.ws.enums import WebsocketMethods, WebsocketSchemes +from wotpy.protocols.ws.messages import \ + WebsocketMessageRequest, \ + WebsocketMessageResponse, \ + WebsocketMessageEmittedItem, \ + WebsocketMessageError, \ + WebsocketMessageException +from wotpy.wot.events import \ + PropertyChangeEmittedEvent, \ + EmittedEvent, \ + PropertyChangeEventInit + + +class WebsocketClient(BaseProtocolClient): + """Implementation of the protocol client interface for the Websocket protocol.""" + + SLEEP_AFTER_ERR_SECS = 1.0 + RECEIVE_LOOP_TERMINATE_SLEEP_SECS = 0.1 + + def __init__(self, receive_timeout_secs=1.0, ping_interval=2000): + self._receive_timeout_secs = receive_timeout_secs + self._ping_interval = ping_interval + self._conns = {} + self._ref_counter = ConnRefCounter() + self._lock_conn = asyncio.Lock() + self._msg_conditions = {} + self._messages = {} + self._receive_stop_events = {} + self._logr = logging.getLogger(__name__) + + async def _init_conn(self, ws_url, ref_id): + """Initializes and connects the WebSockets connection.""" + + async with self._lock_conn: + self._ref_counter.increase(ws_url, ref_id) + + if ws_url in self._conns: + return + + self._logr.debug("Connecting to <{}>".format(ws_url)) + + self._conns[ws_url] = await tornado.websocket.websocket_connect( + ws_url, + ping_interval=self._ping_interval) + + async def _start_receive_loop(): + await self._receive_loop(ws_url) + + self._receive_stop_events[ws_url] = asyncio.Event() + asyncio.create_task(_start_receive_loop()) + + async def _stop_conn(self, ws_url, ref_id): + """Disconnects the WebSockets connection.""" + + async with self._lock_conn: + self._ref_counter.decrease(ws_url, ref_id) + + if self._ref_counter.has_any(ws_url): + return + + try: + if ws_url in self._conns: + self._logr.debug("Disconnecting WS client: {}".format(ws_url)) + self._conns[ws_url].close() + except Exception as ex: + self._logr.warning("Error disconnecting: {}".format(ex), exc_info=True) + + if ws_url in self._receive_stop_events: + self._logr.debug("Stopping message read loop: {}".format(ws_url)) + + self._receive_stop_events[ws_url].set() + + while self._receive_stop_events[ws_url].is_set(): + await asyncio.sleep(self.RECEIVE_LOOP_TERMINATE_SLEEP_SECS) + + self._receive_stop_events.pop(ws_url) + + self._conns.pop(ws_url, None) + self._messages.pop(ws_url, None) + self._msg_conditions.pop(ws_url, None) + + async def _send_message(self, ws_url, msg_req): + """Sends a WebSockets message and returns the condition + that will be notified when the response arrives.""" + + if ws_url not in self._conns: + self._logr.warning("<{}> is not an active connection".format(ws_url)) + return + + if ws_url not in self._msg_conditions: + self._msg_conditions[ws_url] = {} + + if msg_req.id in self._msg_conditions[ws_url]: + self._logr.warning("Message condition already exists") + + await self._conns[ws_url].write_message(msg_req.to_json()) + + msg_condition = asyncio.Condition() + self._msg_conditions[ws_url][msg_req.id] = msg_condition + + return msg_condition + + async def _receive_loop(self, ws_url): + """Starts the WebSockets message receiving loop.""" + + if ws_url not in self._conns: + self._logr.warning("<{}> is not an active connection".format(ws_url)) + return + + if ws_url not in self._messages: + self._messages[ws_url] = {} + + while not self._receive_stop_events[ws_url].is_set(): + try: + raw_res = await self._conns[ws_url].read_message() + + self._logr.debug("Read message: {}".format(raw_res)) + + if raw_res is None: + self._logr.debug("Cannot read message: Closed WS connection") + await asyncio.sleep(self.SLEEP_AFTER_ERR_SECS) + continue + + msg_res = self._parse_msg_response(raw_res) + + if msg_res: + self._messages[ws_url][msg_res.id] = msg_res + conditions = self._msg_conditions.get(ws_url, None) + + if conditions and msg_res.id in conditions: + self._logr.debug("Notifying: {}".format(msg_res.id)) + async with conditions[msg_res.id]: + conditions[msg_res.id].notify_all() + except Exception as ex: + self._logr.warning("Error in read loop: {}".format(ex), exc_info=True) + await asyncio.sleep(self.SLEEP_AFTER_ERR_SECS) + + self._receive_stop_events[ws_url].clear() + + @classmethod + def _parse_msg_response(cls, raw_msg): + """Returns a parsed WS Response message instance if + the raw message format is valid and has the given ID. + Raises Exception if the WS message is an error.""" + + try: + return WebsocketMessageResponse.from_raw(raw_msg) + except WebsocketMessageException: + pass + + try: + return WebsocketMessageError.from_raw(raw_msg) + except WebsocketMessageException: + pass + + return None + + @classmethod + def _parse_emitted_item(cls, raw_msg, sub_id): + """Returns a parsed WS Emitted Item message instance if + the raw message format is valid and has the given ID. + Raises Exception if the WS message is an error.""" + + try: + msg = WebsocketMessageEmittedItem.from_raw(raw_msg) + + if msg.subscription_id == sub_id: + return msg + except WebsocketMessageException: + pass + + try: + err = WebsocketMessageError.from_raw(raw_msg) + err_sub_id = err.data and err.data.get("subscription") + + if err_sub_id == sub_id: + raise Exception(err.message) + except WebsocketMessageException: + pass + + return None + + @property + def protocol(self): + """Protocol of this client instance. + A member of the Protocols enum.""" + + return Protocols.WEBSOCKETS + + def _build_subscribe(self, ws_url, msg_req, on_next): + """Builds the subscribe function that is passed + as an argument on the creation of an Observable.""" + + def subscribe(observer, scheduler): + """Connect to the WS server and start passing the received events to the Observer.""" + + loop = asyncio.get_running_loop() + future_sub_id = loop.create_future() + future_ws_conn = loop.create_future() + + def on_error(ex): + observer.on_error(ex) + + def on_next_event(raw_msg): + sub_id = future_sub_id.result() + + try: + msg_item = self._parse_emitted_item(raw_msg, sub_id) + except Exception as ex: + return on_error(ex) + + if msg_item is None: + return + + try: + on_next(observer, msg_item) + except Exception as ex: + return on_error(ex) + + def parse_subscription_id(raw_msg): + msg_res = self._parse_msg_response(raw_msg) + + if isinstance(msg_res, WebsocketMessageError): + return on_error(Exception(msg_res.message)) + + if msg_res is None: + return + + sub_id = msg_res.result + future_sub_id.set_result(sub_id) + + def on_msg(raw_msg): + if raw_msg is None: + return on_error(Exception("WS connection closed")) + + if future_sub_id.done(): + on_next_event(raw_msg) + else: + parse_subscription_id(raw_msg) + + def on_conn(ft): + try: + ws_conn = ft.result() + except Exception as ex: + return on_error(ex) + + future_ws_conn.set_result(ws_conn) + ws_conn.write_message(msg_req.to_json()) + + tornado.websocket.websocket_connect(ws_url, callback=on_conn, on_message_callback=on_msg) + + def unsubscribe(): + if future_ws_conn.done(): + ws_conn = future_ws_conn.result() + ws_conn.close() + + return unsubscribe + + return subscribe + + def is_supported_interaction(self, td, name): + """Returns True if the any of the Forms for the Interaction + with the given name is supported in this Protocol Binding client.""" + + forms = td.get_forms(name) + + forms_wss = [ + form for form in forms + if is_scheme_form(form, td.base, WebsocketSchemes.WSS) + ] + + forms_ws = [ + form for form in forms + if is_scheme_form(form, td.base, WebsocketSchemes.WS) + ] + + return len(forms_wss) or len(forms_ws) + + def _raise_message(self, ws_url, msg_id): + """Raises the error or return Exception from the message + in the internal collection matching the given ID.""" + + assert ws_url in self._messages, "Unknown WS connection" + assert msg_id in self._messages[ws_url], "Unknown message ID" + + msg = self._messages[ws_url][msg_id] + + if isinstance(msg, WebsocketMessageError): + raise Exception(msg.message) + else: + return msg.result + + async def _wait_condition(self, condition): + """Acquires the lock of the condition and waits on it.""" + async with condition: + await condition.wait() + + async def invoke_action(self, td, name, input_value, timeout=None): + """Invokes an Action on a remote Thing. + Returns a Future.""" + + if name not in td.actions: + raise FormNotFoundException() + + form = pick_form( + td, td.get_action_forms(name), + WebsocketSchemes.list()) + + if not form: + raise FormNotFoundException() + + ws_url = form.resolve_uri(td.base) + ref_id = uuid.uuid4().hex + + try: + await self._init_conn(ws_url, ref_id) + + msg_req = WebsocketMessageRequest( + method=WebsocketMethods.INVOKE_ACTION, + params={"name": name, "parameters": input_value}, + msg_id=uuid.uuid4().hex) + + condition = await self._send_message(ws_url, msg_req) + + try: + await asyncio.wait_for(self._wait_condition(condition), timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout() + + return self._raise_message(ws_url, msg_req.id) + finally: + await self._stop_conn(ws_url, ref_id) + + async def write_property(self, td, name, value, timeout=None): + """Updates the value of a Property on a remote Thing. + Returns a Future.""" + + if name not in td.properties: + raise FormNotFoundException() + + form = pick_form( + td, td.get_property_forms(name), + WebsocketSchemes.list()) + + if not form: + raise FormNotFoundException() + + ws_url = form.resolve_uri(td.base) + ref_id = uuid.uuid4().hex + + try: + await self._init_conn(ws_url, ref_id) + + msg_req = WebsocketMessageRequest( + method=WebsocketMethods.WRITE_PROPERTY, + params={"name": name, "value": value}, + msg_id=uuid.uuid4().hex) + + condition = await self._send_message(ws_url, msg_req) + + try: + await asyncio.wait_for(self._wait_condition(condition), timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout() + + return self._raise_message(ws_url, msg_req.id) + finally: + await self._stop_conn(ws_url, ref_id) + + async def read_property(self, td, name, timeout=None): + """Reads the value of a Property on a remote Thing. + Returns a Future.""" + + if name not in td.properties: + raise FormNotFoundException() + + form = pick_form( + td, td.get_property_forms(name), + WebsocketSchemes.list()) + + if not form: + raise FormNotFoundException() + + ws_url = form.resolve_uri(td.base) + ref_id = uuid.uuid4().hex + + try: + await self._init_conn(ws_url, ref_id) + + msg_req = WebsocketMessageRequest( + method=WebsocketMethods.READ_PROPERTY, + params={"name": name}, + msg_id=uuid.uuid4().hex) + + condition = await self._send_message(ws_url, msg_req) + + try: + await asyncio.wait_for(self._wait_condition(condition), timeout=timeout) + except asyncio.TimeoutError: + raise ClientRequestTimeout + + return self._raise_message(ws_url, msg_req.id) + finally: + await self._stop_conn(ws_url, ref_id) + + def on_event(self, td, name): + """Subscribes to an event on a remote Thing. + Returns an Observable.""" + + if name not in td.events: + # noinspection PyUnresolvedReferences + return reactivex.throw(FormNotFoundException()) + + form = pick_form( + td, td.get_event_forms(name), + WebsocketSchemes.list()) + + if not form: + # noinspection PyUnresolvedReferences + return reactivex.throw(FormNotFoundException()) + + ws_url = form.resolve_uri(td.base) + + msg_req = WebsocketMessageRequest( + method=WebsocketMethods.ON_EVENT, + params={"name": name}, + msg_id=uuid.uuid4().hex) + + def on_next(observer, msg_item): + observer.on_next(EmittedEvent(init=msg_item.data, name=name)) + + subscribe = self._build_subscribe(ws_url, msg_req, on_next) + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_property_change(self, td, name): + """Subscribes to property changes on a remote Thing. + Returns an Observable.""" + + if name not in td.properties: + # noinspection PyUnresolvedReferences + return reactivex.throw(FormNotFoundException()) + + form = pick_form( + td, td.get_property_forms(name), + WebsocketSchemes.list()) + + if not form: + # noinspection PyUnresolvedReferences + return reactivex.throw(FormNotFoundException()) + + ws_url = form.resolve_uri(td.base) + + msg_req = WebsocketMessageRequest( + method=WebsocketMethods.ON_PROPERTY_CHANGE, + params={"name": name}, + msg_id=uuid.uuid4().hex) + + def on_next(observer, msg_item): + init_name = msg_item.data["name"] + init_value = msg_item.data["value"] + init = PropertyChangeEventInit(name=init_name, value=init_value) + observer.on_next(PropertyChangeEmittedEvent(init=init)) + + subscribe = self._build_subscribe(ws_url, msg_req, on_next) + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def on_td_change(self, url): + """Subscribes to Thing Description changes on a remote Thing. + Returns an Observable.""" + + raise NotImplementedError diff --git a/wotpy/protocols/ws/enums.py b/wotpy/protocols/ws/enums.py new file mode 100644 index 0000000..9b12ff1 --- /dev/null +++ b/wotpy/protocols/ws/enums.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Enumeration classes related to the WebSockets server. +""" + +from wotpy.utils.enums import EnumListMixin + + +class WebsocketMethods(EnumListMixin): + """Enumeration of available websocket message actions.""" + + READ_PROPERTY = "read_property" + WRITE_PROPERTY = "write_property" + INVOKE_ACTION = "invoke_action" + ON_PROPERTY_CHANGE = "on_property_change" + ON_TD_CHANGE = "on_td_change" + ON_EVENT = "on_event" + DISPOSE = "dispose" + + +class WebsocketErrors(EnumListMixin): + """Enumeration of JSON RPC error codes.""" + + PARSE_ERROR = -32700 + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_METHOD_PARAMS = -32602 + INTERNAL_ERROR = -32603 + SUBSCRIPTION_ERROR = -32000 + + +class WebsocketSchemes(EnumListMixin): + """Enumeration of Websocket schemes.""" + + WS = "ws" + WSS = "wss" diff --git a/wotpy/protocols/ws/handler.py b/wotpy/protocols/ws/handler.py new file mode 100644 index 0000000..1dd942d --- /dev/null +++ b/wotpy/protocols/ws/handler.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that handles incoming WebSockets messages. +""" + +import asyncio +import uuid + +from jsonschema import validate, ValidationError +from reactivex.scheduler.eventloop import IOLoopScheduler +from tornado import websocket +from tornado import ioloop + +from wotpy.protocols.ws.enums import WebsocketMethods, WebsocketErrors +from wotpy.protocols.ws.messages import \ + WebsocketMessageRequest, \ + WebsocketMessageException, \ + WebsocketMessageError, \ + WebsocketMessageResponse, \ + WebsocketMessageEmittedItem +from wotpy.protocols.ws.schemas import \ + SCHEMA_PARAMS_READ_PROPERTY, \ + SCHEMA_PARAMS_WRITE_PROPERTY, \ + SCHEMA_PARAMS_DISPOSE, \ + SCHEMA_PARAMS_INVOKE_ACTION, \ + SCHEMA_PARAMS_ON_PROPERTY_CHANGE, \ + SCHEMA_PARAMS_ON_TD_CHANGE, \ + SCHEMA_PARAMS_ON_EVENT + + +# noinspection PyAbstractClass +class WebsocketHandler(websocket.WebSocketHandler): + """Tornado handler for Websocket messages. + This class processes all incoming WebSocket messages and + translates them to actions executed on ExposedThing objects.""" + + POLICY_VIOLATION_CODE = 1008 + POLICY_VIOLATION_REASON = "Not found" + + def __init__(self, *args, **kwargs): + self._server = kwargs.pop("websocket_server", None) + loop = ioloop.IOLoop.current() + self._scheduler = IOLoopScheduler(loop) + self._subscriptions = {} + self._exposed_thing_name = None + super(WebsocketHandler, self).__init__(*args, **kwargs) + + @property + def exposed_thing(self): + """Exposed thing property. + Retrieves the ExposedThing from the parent server.""" + + try: + return self._server.get_exposed_thing(self._exposed_thing_name) + except ValueError: + self.close(self.POLICY_VIOLATION_CODE, self.POLICY_VIOLATION_REASON) + + def check_origin(self, origin): + """Should return True to accept the request or False to reject it. + The origin argument is the value of the Origin HTTP header, + the url responsible for initiating this request""" + + # ToDo: Check this once we add authentication + # This is extremely dangerous in case of a cookie-based authentication system. + # WS authentication should be handled independently with some kind of token-based system. + + return True + + def open(self, name): + """Called when the WebSockets connection is opened.""" + + assert self._exposed_thing_name is None + + try: + self._server.get_exposed_thing(name) + self._exposed_thing_name = name + except ValueError: + self.close(self.POLICY_VIOLATION_CODE, self.POLICY_VIOLATION_REASON) + + def _write_error(self, message, code, msg_id=None, data=None): + """Builds an error message instance and sends it to the client.""" + + err = WebsocketMessageError(message=message, code=code, data=data, msg_id=msg_id) + self.write_message(err.to_json()) + + def _dispose_subscription(self, subscription_id): + """Takes a subscription ID and destroys the related subscription.""" + + if subscription_id in self._subscriptions: + subscription = self._subscriptions.pop(subscription_id) + subscription.dispose() + + def _on_subscription_error(self, subscription_id, err): + """Default error callback for Observable subscriptions.""" + + self._dispose_subscription(subscription_id) + data_err = {"subscription": subscription_id} + self._write_error(str(err), WebsocketErrors.SUBSCRIPTION_ERROR, data=data_err) + + def _on_subscription_next(self, subscription_id, item): + """Default next callback for Observable subscriptions.""" + + try: + msg = WebsocketMessageEmittedItem( + subscription_id=subscription_id, + name=item.name, + data=item.data) + self.write_message(msg.to_json()) + except WebsocketMessageException as ex: + self._on_subscription_error(subscription_id, ex) + + def _on_subscription_completed(self, subscription_id): + """Default completed callback for Observable subscriptions.""" + + self._dispose_subscription(subscription_id) + + def _subscribe(self, subscription_id, observable): + """Subscribe to the given Observable and add the subscription handler to the internal dict.""" + + subscription = observable.subscribe( + on_next=lambda item: self._on_subscription_next(subscription_id, item), + on_error=lambda err: self._on_subscription_error(subscription_id, err), + on_completed=lambda: self._on_subscription_completed(subscription_id), + scheduler=self._scheduler) + + self._subscriptions[subscription_id] = subscription + + async def _handle_get_property(self, req): + """Handler for the 'get_property' method.""" + + params = req.params + + try: + validate(params, SCHEMA_PARAMS_READ_PROPERTY) + except ValidationError as ex: + self._write_error(str(ex), WebsocketErrors.INVALID_METHOD_PARAMS, msg_id=req.id) + return + + try: + prop_value = await self.exposed_thing.read_property(name=params["name"]) + except Exception as ex: + self._write_error(str(ex), WebsocketErrors.INTERNAL_ERROR, msg_id=req.id) + return + + res = WebsocketMessageResponse(result=prop_value, msg_id=req.id) + self.write_message(res.to_json()) + + async def _handle_set_property(self, req): + """Handler for the 'set_property' method.""" + + params = req.params + + try: + validate(params, SCHEMA_PARAMS_WRITE_PROPERTY) + except ValidationError as ex: + self._write_error(str(ex), WebsocketErrors.INVALID_METHOD_PARAMS, msg_id=req.id) + return + + try: + await self.exposed_thing.handle_write_property(params["name"], params["value"]) + except Exception as ex: + self._write_error(str(ex), WebsocketErrors.INTERNAL_ERROR, msg_id=req.id) + return + + res = WebsocketMessageResponse(result=None, msg_id=req.id) + self.write_message(res.to_json()) + + async def _handle_invoke_action(self, req): + """Handler for the 'invoke_action' method.""" + + params = req.params + + try: + validate(params, SCHEMA_PARAMS_INVOKE_ACTION) + except ValidationError as ex: + self._write_error(str(ex), WebsocketErrors.INVALID_METHOD_PARAMS, msg_id=req.id) + return + + try: + input_value = params.get("parameters") + action_result = await self.exposed_thing.invoke_action(params["name"], input_value) + except Exception as ex: + self._write_error(str(ex), WebsocketErrors.INTERNAL_ERROR, msg_id=req.id) + return + + res = WebsocketMessageResponse(result=action_result, msg_id=req.id) + self.write_message(res.to_json()) + + async def _handle_on_property_change(self, req): + """Handler for the 'on_property_change' subscription method.""" + + params = req.params + + try: + validate(params, SCHEMA_PARAMS_ON_PROPERTY_CHANGE) + except ValidationError as ex: + self._write_error(str(ex), WebsocketErrors.INVALID_METHOD_PARAMS, msg_id=req.id) + return + + subscription_id = str(uuid.uuid4()) + + res = WebsocketMessageResponse(result=subscription_id, msg_id=req.id) + self.write_message(res.to_json()) + + observable = self.exposed_thing.on_property_change(name=params["name"]) + + self._subscribe(subscription_id, observable) + + async def _handle_on_td_change(self, req): + """Handler for the 'on_td_change' subscription method.""" + + params = req.params + + try: + validate(params, SCHEMA_PARAMS_ON_TD_CHANGE) + except ValidationError as ex: + self._write_error(str(ex), WebsocketErrors.INVALID_METHOD_PARAMS, msg_id=req.id) + return + + subscription_id = str(uuid.uuid4()) + + res = WebsocketMessageResponse(result=subscription_id, msg_id=req.id) + self.write_message(res.to_json()) + + observable = self.exposed_thing.on_td_change() + + self._subscribe(subscription_id, observable) + + async def _handle_on_event(self, req): + """Handler for the 'on_event' subscription method.""" + + params = req.params + + try: + validate(params, SCHEMA_PARAMS_ON_EVENT) + except ValidationError as ex: + self._write_error(str(ex), WebsocketErrors.INVALID_METHOD_PARAMS, msg_id=req.id) + return + + subscription_id = str(uuid.uuid4()) + + res = WebsocketMessageResponse(result=subscription_id, msg_id=req.id) + self.write_message(res.to_json()) + + observable = self.exposed_thing.on_event(name=params["name"]) + + self._subscribe(subscription_id, observable) + + async def _handle_dispose(self, req): + """Handler for the 'dispose' method.""" + + params = req.params + + try: + validate(params, SCHEMA_PARAMS_DISPOSE) + except ValidationError as ex: + self._write_error(str(ex), WebsocketErrors.INVALID_METHOD_PARAMS, msg_id=req.id) + return + + result = None + subscription_id = params["subscription"] + + if subscription_id in self._subscriptions: + self._dispose_subscription(subscription_id) + result = subscription_id + + res = WebsocketMessageResponse(result=result, msg_id=req.id) + self.write_message(res.to_json()) + + async def _handle(self, req): + """Takes a WebsocketMessageRequest instance and routes + the request to the required method handler.""" + + handler_map = { + WebsocketMethods.READ_PROPERTY: self._handle_get_property, + WebsocketMethods.WRITE_PROPERTY: self._handle_set_property, + WebsocketMethods.INVOKE_ACTION: self._handle_invoke_action, + WebsocketMethods.ON_PROPERTY_CHANGE: self._handle_on_property_change, + WebsocketMethods.ON_TD_CHANGE: self._handle_on_td_change, + WebsocketMethods.ON_EVENT: self._handle_on_event, + WebsocketMethods.DISPOSE: self._handle_dispose, + } + + if req.method not in handler_map: + self._write_error("Unimplemented method", WebsocketErrors.INTERNAL_ERROR, msg_id=req.id) + return + + handler = handler_map[req.method] + await handler(req) + + async def on_message(self, message): + """Called each time the server receives a WebSockets message. + All messages that do not conform to the protocol are discarded.""" + + try: + req = WebsocketMessageRequest.from_raw(message) + asyncio.ensure_future(self._handle(req)) + except WebsocketMessageException as ex: + self._write_error(str(ex), WebsocketErrors.INTERNAL_ERROR) + + def on_close(self): + """Called when the WebSockets connection is closed.""" + + for subscription_id in list(self._subscriptions.keys()): + self._dispose_subscription(subscription_id) diff --git a/wotpy/protocols/ws/messages.py b/wotpy/protocols/ws/messages.py new file mode 100644 index 0000000..fc2b13a --- /dev/null +++ b/wotpy/protocols/ws/messages.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that represent JSON-RPC messages exchanged over WebSockets. +""" + +import json + +from jsonschema import validate, ValidationError + +from wotpy.protocols.ws.enums import WebsocketErrors +from wotpy.protocols.ws.schemas import \ + SCHEMA_REQUEST, \ + SCHEMA_RESPONSE, \ + SCHEMA_EMITTED_ITEM, \ + SCHEMA_ERROR, \ + JSON_RPC_VERSION +from wotpy.utils.utils import to_json_obj + + +def parse_ws_message(raw_msg): + """Takes a raw WebSockets message and attempts + to parse it to create a message instance.""" + + msg_klasses = [ + WebsocketMessageRequest, + WebsocketMessageError, + WebsocketMessageResponse, + WebsocketMessageEmittedItem + ] + + for klass in msg_klasses: + try: + msg_instance = klass.from_raw(raw_msg) + return msg_instance + except WebsocketMessageException: + pass + + raise WebsocketMessageException("Invalid message: {}".format(raw_msg)) + + +class WebsocketMessageException(Exception): + """Exception raised when a WS message appears to be invalid.""" + + pass + + +class WebsocketMessageRequest: + """Represents a message received on a websocket that + contains a JSON-RPC WoT action request.""" + + @classmethod + def from_raw(cls, raw_msg): + """Builds a new WebsocketMessageRequest instance from a raw socket message. + Raises WebsocketMessageException if the message is invalid.""" + + try: + msg = json.loads(raw_msg) + validate(msg, SCHEMA_REQUEST) + + return WebsocketMessageRequest( + method=msg["method"], + params=msg["params"], + msg_id=msg.get("id", None)) + except Exception as ex: + raise WebsocketMessageException(str(ex)) + + def __init__(self, method, params, msg_id=None): + self.method = method + self.params = params + self.msg_id = msg_id + + try: + validate(self.to_dict(), SCHEMA_REQUEST) + except ValidationError as ex: + raise WebsocketMessageError(str(ex)) + + @property + def id(self): + """ID property.""" + + return self.msg_id + + def to_dict(self): + """Returns this message as a dict.""" + + msg = { + "jsonrpc": JSON_RPC_VERSION, + "method": self.method, + "params": self.params, + "id": self.id + } + + return msg + + def to_json(self): + """Returns this message as a JSON string.""" + + return json.dumps(self.to_dict()) + + +class WebsocketMessageResponse: + """Represents a WoT Websockets JSON-RPC response message.""" + + @classmethod + def from_raw(cls, raw_msg): + """Builds a new WebsocketMessageResponse instance from a raw socket message. + Raises WebsocketMessageException if the message is invalid.""" + + try: + msg = json.loads(raw_msg) + validate(msg, SCHEMA_RESPONSE) + + return WebsocketMessageResponse( + result=msg["result"], + msg_id=msg.get("id", None)) + except Exception as ex: + raise WebsocketMessageException(str(ex)) + + def __init__(self, result, msg_id=None): + self.result = result + self.msg_id = msg_id + + try: + validate(self.to_dict(), SCHEMA_RESPONSE) + except ValidationError as ex: + raise WebsocketMessageError(str(ex)) + + @property + def id(self): + """ID property.""" + + return self.msg_id + + def to_dict(self): + """Returns this message as a dict.""" + + msg = { + "jsonrpc": JSON_RPC_VERSION, + "result": self.result, + "id": self.id + } + + return msg + + def to_json(self): + """Returns this message as a JSON string.""" + + return json.dumps(self.to_dict()) + + +class WebsocketMessageError: + """Represents a WoT Websockets JSON-RPC error message.""" + + @classmethod + def from_raw(cls, raw_msg): + """Builds a new WebsocketMessageError instance from a raw socket message. + Raises WebsocketMessageException if the message is invalid.""" + + try: + msg = json.loads(raw_msg) + validate(msg, SCHEMA_ERROR) + + return WebsocketMessageError( + message=msg["error"]["message"], + code=msg["error"]["code"], + data=msg["error"].get("data", None), + msg_id=msg.get("id", None)) + except Exception as ex: + raise WebsocketMessageException(str(ex)) + + def __init__(self, message, code=WebsocketErrors.INTERNAL_ERROR, data=None, msg_id=None): + self.message = message + self.msg_id = msg_id + self.code = code + self.data = data + + try: + validate(self.to_dict(), SCHEMA_ERROR) + except ValidationError as ex: + raise WebsocketMessageError(str(ex)) + + @property + def id(self): + """ID property.""" + + return self.msg_id + + def to_dict(self): + """Returns this message as a dict.""" + + msg = { + "jsonrpc": JSON_RPC_VERSION, + "error": { + "code": self.code, + "message": self.message, + "data": self.data + }, + "id": self.id + } + + return msg + + def to_json(self): + """Returns this message as a JSON string.""" + + return json.dumps(self.to_dict()) + + +class WebsocketMessageEmittedItem: + """Represents a Websockets message for an item emitted by an active subscription.""" + + @classmethod + def from_raw(cls, raw_msg): + """Builds a new WebsocketMessageEmittedItem instance from a raw socket message. + Raises WebsocketMessageException if the message is invalid.""" + + try: + msg = json.loads(raw_msg) + validate(msg, SCHEMA_EMITTED_ITEM) + + return WebsocketMessageEmittedItem( + subscription_id=msg["subscription"], + name=msg["name"], + data=msg["data"]) + except Exception as ex: + raise WebsocketMessageException(str(ex)) + + def __init__(self, subscription_id, name, data): + self.subscription_id = subscription_id + self.name = name + self.data = to_json_obj(data) + + try: + validate(self.to_dict(), SCHEMA_EMITTED_ITEM) + except ValidationError as ex: + raise WebsocketMessageError(str(ex)) + + def to_dict(self): + """Returns this message as a dict.""" + + msg = { + "subscription": self.subscription_id, + "name": self.name, + "data": self.data + } + + return msg + + def to_json(self): + """Returns this message as a JSON string.""" + + return json.dumps(self.to_dict()) diff --git a/wotpy/protocols/ws/schemas.py b/wotpy/protocols/ws/schemas.py new file mode 100644 index 0000000..07a8066 --- /dev/null +++ b/wotpy/protocols/ws/schemas.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Schemas following the JSON Schema specification used to validate the shape of WebSockets messages. +""" + +from wotpy.protocols.ws.enums import WebsocketMethods + +JSON_RPC_VERSION = "2.0" + +# Schema for message IDs + +SCHEMA_ID = { + "oneOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "null"} + ] +} + +SCHEMA_REQUEST = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-request.json", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": [JSON_RPC_VERSION] + }, + "method": { + "type": "string", + "enum": WebsocketMethods.list() + }, + "params": { + "oneOf": [ + {"type": "object"}, + {"type": "array"} + ] + }, + "id": SCHEMA_ID + }, + "required": [ + "jsonrpc", + "method" + ] +} + +SCHEMA_RESPONSE = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-response.json", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + }, + "result": {}, + "id": SCHEMA_ID + }, + "required": [ + "jsonrpc", + "result", + "id" + ] +} + +SCHEMA_ERROR = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-error.json", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + }, + "error": { + "type": "object", + "properties": { + "code": {"type": "integer"}, + "message": {"type": "string"}, + "data": {} + }, + "required": [ + "code", + "message" + ] + }, + "id": SCHEMA_ID + }, + "required": [ + "jsonrpc", + "error", + "id" + ] +} + +SCHEMA_EMITTED_ITEM = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-emitted-item.json", + "type": "object", + "properties": { + "subscription": {"type": "string"}, + "name": {"type": "string"}, + "data": {} + }, + "required": [ + "subscription", + "name", + "data" + ] +} + +SCHEMA_PARAMS_READ_PROPERTY = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-params-read-property.json", + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": [ + "name" + ] +} + +SCHEMA_PARAMS_WRITE_PROPERTY = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-params-write-property.json", + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {} + }, + "required": [ + "name", + "value" + ] +} + +SCHEMA_PARAMS_INVOKE_ACTION = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-params-invoke-action.json", + "type": "object", + "properties": { + "name": {"type": "string"}, + "parameters": {} + }, + "required": [ + "name" + ] +} + +SCHEMA_PARAMS_ON_PROPERTY_CHANGE = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-params-on-property-change.json", + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": [ + "name" + ] +} + +SCHEMA_PARAMS_ON_TD_CHANGE = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-params-on-td-change.json", + "type": "object" +} + +SCHEMA_PARAMS_ON_EVENT = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-params-on-event.json", + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": [ + "name" + ] +} + +SCHEMA_PARAMS_DISPOSE = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "http://fundacionctic.org/schemas/wotpy-ws-params-dispose.json", + "type": "object", + "properties": { + "subscription": {"type": "string"} + }, + "required": [ + "subscription" + ] +} diff --git a/wotpy/protocols/ws/server.py b/wotpy/protocols/ws/server.py new file mode 100644 index 0000000..362553e --- /dev/null +++ b/wotpy/protocols/ws/server.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that implements the WebSockets server. +""" + +from tornado import web +from tornado.httpserver import HTTPServer + +from wotpy.codecs.enums import MediaTypes +from wotpy.protocols.enums import Protocols +from wotpy.protocols.server import BaseProtocolServer +from wotpy.protocols.ws.enums import WebsocketSchemes +from wotpy.protocols.ws.handler import WebsocketHandler +from wotpy.wot.form import Form + + +class WebsocketServer(BaseProtocolServer): + """WebSockets binding server implementation. Builds a Tornado application + that uses the WebsocketHandler handler to process WebSockets messages.""" + + DEFAULT_PORT = 8081 + + def __init__(self, port=DEFAULT_PORT, ssl_context=None): + super(WebsocketServer, self).__init__(port=port) + self._server = None + self._app = self._build_app() + self._ssl_context = ssl_context + self._servient = None + + @property + def protocol(self): + """Protocol of this server instance. + A member of the Protocols enum.""" + + return Protocols.WEBSOCKETS + + @property + def scheme(self): + """Returns the URL scheme for this server.""" + + return WebsocketSchemes.WSS if self.is_secure else WebsocketSchemes.WS + + @property + def is_secure(self): + """Returns True if this server is configured to use SSL encryption.""" + + return self._ssl_context is not None + + @property + def app(self): + """Tornado application property.""" + + return self._app + + def _build_app(self): + """Builds and returns the Tornado application for the WebSockets server.""" + + return web.Application([( + r"/(?P[^\/]+)", + WebsocketHandler, + {"websocket_server": self} + )]) + + def build_forms(self, hostname, interaction): + """Builds and returns a list with all Form that are + linked to this server for the given Interaction.""" + + exposed_thing = self.exposed_thing_set.find_by_interaction(interaction) + + if not exposed_thing: + raise ValueError("Unknown Interaction") + + base_url = self.build_base_url(hostname=hostname, thing=exposed_thing.thing) + + return [ + Form( + interaction=interaction, + protocol=self.protocol, + href=base_url, + content_type=MediaTypes.JSON) + ] + + def build_base_url(self, hostname, thing): + """Returns the base URL for the given Thing in the context of this server.""" + + if not self.exposed_thing_set.find_by_thing_name(thing.title): + raise ValueError("Unknown Thing") + + hostname = hostname.rstrip("/") + + return "{}://{}:{}/{}".format(self.scheme, hostname, self.port, thing.url_name) + + async def start(self, servient=None): + """Starts the WebSockets server.""" + + self._servient = servient + + self._server = HTTPServer(self.app, ssl_options=self._ssl_context) + self._server.listen(self.port) + + async def stop(self): + """Stops the WebSockets server.""" + + if not self._server: + return + + self._server.stop() + self._server = None diff --git a/wotpy/support.py b/wotpy/support.py new file mode 100644 index 0000000..9f0e6ec --- /dev/null +++ b/wotpy/support.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Functions to check if some functionalities are enabled in the current platform. +""" + +import platform +import sys + +FEATURE_DNSSD = 'DNSSD' +FEATURE_COAP = 'COAP' +FEATURE_MQTT = 'MQTT' + +FEATURE_REQUISITES = { + FEATURE_DNSSD: { + 'max_version': (3, 12, 0), + 'min_version': (3, 4, 0), + 'platforms': ['Linux', 'Darwin'] + }, + FEATURE_COAP: { + 'max_version': (3, 12, 0), + 'min_version': (3, 4, 0), + 'platforms': ['Linux'] + }, + FEATURE_MQTT: { + 'max_version': (3, 12, 0), + 'min_version': (3, 4, 0), + 'platforms': ['Linux', 'Darwin'] + } +} + + +def is_supported(feature): + """Returns True if the given feature is supported in this platform.""" + + reqs = FEATURE_REQUISITES.get(feature) + + if not reqs: + raise ValueError("Unknown feature: {}".format(feature)) + + min_version = reqs.get('min_version') + + if min_version and sys.version_info < min_version: + return False + + max_version = reqs.get('max_version') + + if max_version and sys.version_info > max_version: + return False + + #platforms = reqs.get('platforms') + + #if platforms and platform.system() not in platforms: + # return False + + return True + + +def is_coap_supported(): + """Returns True if the CoAP binding is supported in this platform.""" + + return is_supported(FEATURE_COAP) + + +def is_mqtt_supported(): + """Returns True if the MQTT binding is supported in this platform.""" + + return is_supported(FEATURE_MQTT) + + +def is_dnssd_supported(): + """Returns True if DNS-SD is supported in this platform.""" + + return is_supported(FEATURE_DNSSD) diff --git a/wotpy/utils/__init__.py b/wotpy/utils/__init__.py new file mode 100644 index 0000000..33a172d --- /dev/null +++ b/wotpy/utils/__init__.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Utility functions and classes. + +.. autosummary:: + :toctree: _utils + + wotpy.utils.enums + wotpy.utils.utils +""" diff --git a/wotpy/utils/enums.py b/wotpy/utils/enums.py new file mode 100644 index 0000000..bd54d7a --- /dev/null +++ b/wotpy/utils/enums.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Utilities related to enumerations. +""" + + +class EnumListMixin: + """Mixin that provides methods to list enumerated values.""" + + @classmethod + def list(cls): + """Returns a list of enumerated values.""" + + def _is_enumerate_item(attr_name, attr_val): + return not attr_name.startswith("__") \ + and isinstance(attr_val, str) \ + and attr_name.isupper() + + return [ + val for (name, val) in cls.__dict__.items() + if _is_enumerate_item(name, val)] diff --git a/wotpy/utils/proxy.py b/wotpy/utils/proxy.py new file mode 100644 index 0000000..de87922 --- /dev/null +++ b/wotpy/utils/proxy.py @@ -0,0 +1,122 @@ +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +import asyncio +import logging + +def init_logging(): + """Initializes the logging subsystem.""" + + LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + logging.basicConfig(format=LOG_FORMAT) + logger = logging.getLogger() + logger.setLevel(logging.INFO) + logging.getLogger('wotpy').setLevel(logging.DEBUG) + +init_logging() +logger = logging.getLogger() + +SUB_DELAY = 2.0 + +TIMEOUT_PROP_READ = 120.0 +TIMEOUT_PROP_WRITE = 120.0 +TIMEOUT_ACTION_INVOCATION = 1800.0 +TIMEOUT_HARD_FACTOR = 1.2 + + +def build_prop_read_proxy(consumed_thing, name): + """Factory for proxy Property read handlers.""" + + async def _proxy(): + timeout_soft = TIMEOUT_PROP_READ + timeout_hard = TIMEOUT_PROP_READ * TIMEOUT_HARD_FACTOR + + awaitable = consumed_thing.properties[name].read(timeout=timeout_soft) + + return await asyncio.wait_for(awaitable, timeout=timeout_hard) + + return _proxy + + +def build_prop_write_proxy(consumed_thing, name): + """Factory for proxy Property write handlers.""" + + async def _proxy(val): + timeout_soft = TIMEOUT_PROP_WRITE + timeout_hard = TIMEOUT_PROP_WRITE * TIMEOUT_HARD_FACTOR + + awaitable = consumed_thing.properties[name].write(val, timeout=timeout_soft) + + await asyncio.wait_for(awaitable, timeout=timeout_hard) + + return _proxy + + +def build_action_invoke_proxy(consumed_thing, name): + """Factory for proxy Action invocation handlers.""" + + async def _proxy(params): + timeout_soft = TIMEOUT_ACTION_INVOCATION + timeout_hard = TIMEOUT_ACTION_INVOCATION * TIMEOUT_HARD_FACTOR + + awaitable = consumed_thing.actions[name].invoke(params.get('input'), timeout=timeout_soft) + + return await asyncio.wait_for(awaitable, timeout=timeout_hard) + + return _proxy + + +def subscribe_event(consumed_thing, exposed_thing, name): + """Creates and maintains a subscription to the given Event, recreating it on error.""" + + state = {'sub': None} + + def _on_next(item): + logger.info("{}".format(item)) + exposed_thing.events[name].emit(item.data) + + def _on_completed(): + logger.info("Completed (Event {})".format(name)) + + def _on_error(err): + logger.warning("Error (Event {}) :: {}".format(name, err)) + + try: + logger.warning("Disposing of erroneous subscription") + state['sub'].dispose() + except Exception as ex: + logger.warning("Error disposing: {}".format(ex), exc_info=True) + + def _sub(): + logger.warning("Recreating subscription") + state['sub'] = consumed_thing.events[name].subscribe( + on_next=_on_next, + on_completed=_on_completed, + on_error=_on_error) + + logger.warning("Re-creating subscription in {} seconds".format(SUB_DELAY)) + + asyncio.get_event_loop().call_later(SUB_DELAY, _sub) + + state['sub'] = consumed_thing.events[name].subscribe( + on_next=_on_next, + on_completed=_on_completed, + on_error=_on_error) diff --git a/wotpy/utils/utils.py b/wotpy/utils/utils.py new file mode 100644 index 0000000..4c27aa3 --- /dev/null +++ b/wotpy/utils/utils.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Some utility functions for the WoT data type wrappers. +""" + +import json +import socket +from functools import wraps + + +def merge_args_kwargs_dict(args, kwargs): + """Takes a tuple of args and dict of kwargs. + Returns a dict that is the result of merging the first item + of args (if that item is a dict) and the kwargs dict.""" + + init_dict = {} + + if len(args) > 0 and isinstance(args[0], dict): + init_dict = args[0] + + init_dict.update(kwargs) + + return init_dict + + +def to_camel(val): + """Takes a string and transforms it to camelCase.""" + + if not isinstance(val, str): + raise ValueError + + parts = val.split("_") + parts = parts[:1] + [item.title() for item in parts[1:]] + + return "".join(parts) + + +def to_snake(val): + """Takes a string and transforms it to snake_case.""" + + if not isinstance(val, str): + raise ValueError + + return "".join(["_" + x.lower() if x.isupper() else x for x in val]) + + +def to_json_obj(obj): + """Recursive function that attempts to convert + any given object to a JSON-serializable object.""" + + if isinstance(obj, set): + return list(obj) + + try: + json.dumps(obj) + return obj + except TypeError: + pass + + try: + return { + key: to_json_obj(val) + for key, val in vars(obj).items() + } + except TypeError: + raise ValueError("Object {} is not JSON serializable".format(obj)) + + +def get_main_ipv4_address(): + """Returns the main IPv4 address of the current machine in a portable fashion. + Attribution to the answer provided by Jamieson Becker on: + https://stackoverflow.com/a/28950776""" + + ip_range = ['10.255.255.255', '10.0.255.255', '10.0.0.255'] + + for ip in ip_range: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect((ip, 1)) + addr = sock.getsockname()[0] + sock.close() + break + except: + addr = '127.0.0.1' + finally: + sock.close() + + return addr + + +def handle_observer_finalization(observer): + """Builds a decorator that yields the wrapped coroutine and calls on_completed + or on_error on the observer when the coroutine ends or raises an error.""" + + def deco(coro): + @wraps(coro) + async def wrapper(*args, **kwargs): + try: + await coro(*args, **kwargs) + observer.on_completed() + except Exception as ex: + observer.on_error(ex) + + return wrapper + + return deco diff --git a/wotpy/wot/__init__.py b/wotpy/wot/__init__.py new file mode 100644 index 0000000..5aa2a11 --- /dev/null +++ b/wotpy/wot/__init__.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Dictionaries and classes defined under the W3C WoT Scripting API specification. + +.. autosummary:: + :toctree: _wot + + wotpy.wot.consumed + wotpy.wot.dictionaries + wotpy.wot.discovery + wotpy.wot.exposed + wotpy.wot.constants + wotpy.wot.enums + wotpy.wot.events + wotpy.wot.form + wotpy.wot.interaction + wotpy.wot.servient + wotpy.wot.td + wotpy.wot.thing + wotpy.wot.validation + wotpy.wot.wot +""" diff --git a/wotpy/wot/constants.py b/wotpy/wot/constants.py new file mode 100644 index 0000000..032a90c --- /dev/null +++ b/wotpy/wot/constants.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Constants related to objects in the Thing hierarchy. +""" + +WOT_TD_CONTEXT_URL_V1 = "https://www.w3.org/2019/wot/td/v1" +WOT_TD_CONTEXT_URL_V1_1 = "https://www.w3.org/2022/wot/td/v1.1" diff --git a/wotpy/wot/consumed/__init__.py b/wotpy/wot/consumed/__init__.py new file mode 100644 index 0000000..5446b89 --- /dev/null +++ b/wotpy/wot/consumed/__init__.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +ConsumedThing and related entities. + +.. autosummary:: + :toctree: _consumed + + wotpy.wot.consumed.interaction_map + wotpy.wot.consumed.thing +""" diff --git a/wotpy/wot/consumed/interaction_map.py b/wotpy/wot/consumed/interaction_map.py new file mode 100644 index 0000000..ec90ca0 --- /dev/null +++ b/wotpy/wot/consumed/interaction_map.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that represent Interaction instances accessed on a ConsumedThing. +""" + +from collections import UserDict + +from reactivex.scheduler.eventloop import IOLoopScheduler +from slugify import slugify +from tornado import ioloop + + +class ConsumedThingInteractionDict(UserDict): + """A dictionary that provides lazy access to the objects that implement + the Interaction interface for each interaction in a given ConsumedThing.""" + + def __init__(self, *args, **kwargs): + self._consumed_thing = kwargs.pop("consumed_thing") + UserDict.__init__(self, *args, **kwargs) + + def _find_normalized_name(self, name): + """Takes a case-insensitive URL-safe interaction name and returns + the actual name in the interaction dict.""" + + return next((key for key in self.interaction_dict.keys() if slugify(key) == slugify(name)), None) + + def __getitem__(self, name): + """Lazily build and return an object that implements the Interaction interface.""" + + name_normalized = self._find_normalized_name(name) + + if name_normalized is None: + raise KeyError("Unknown interaction: {}".format(name)) + + return self.thing_interaction_class(self._consumed_thing, name_normalized) + + def __len__(self): + return len(self.interaction_dict) + + def __contains__(self, item): + return self._find_normalized_name(item) is not None + + def __iter__(self): + return iter(self.interaction_dict.keys()) + + @property + def interaction_dict(self): + """Returns an interactions dict by name. + The dict values are the raw dict interactions as contained in a TD document.""" + + raise NotImplementedError() + + @property + def thing_interaction_class(self): + """Returns the class that implements the + Interaction interface for this type of interaction.""" + + raise NotImplementedError() + + +class ConsumedThingPropertyDict(ConsumedThingInteractionDict): + """A dictionary that provides lazy access to the objects that implement + the ThingProperty interface for each property in a given ConsumedThing.""" + + @property + def interaction_dict(self): + return self._consumed_thing.td.properties + + @property + def thing_interaction_class(self): + return ConsumedThingProperty + + +class ConsumedThingActionDict(ConsumedThingInteractionDict): + """A dictionary that provides lazy access to the objects that implement + the ThingAction interface for each action in a given ConsumedThing.""" + + @property + def interaction_dict(self): + return self._consumed_thing.td.actions + + @property + def thing_interaction_class(self): + return ConsumedThingAction + + +class ConsumedThingEventDict(ConsumedThingInteractionDict): + """A dictionary that provides lazy access to the objects that implement + the ThingEvent interface for each event in a given ConsumedThing.""" + + @property + def interaction_dict(self): + return self._consumed_thing.td.events + + @property + def thing_interaction_class(self): + return ConsumedThingEvent + + +class ConsumedThingProperty: + """The ThingProperty interface implementation for ConsumedThing objects.""" + + def __init__(self, consumed_thing, name): + self._consumed_thing = consumed_thing + self._name = name + + def __str__(self): + return "<{}> ({}::{})".format(self.__class__.__name__, self._consumed_thing.id, self._name) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private init dict before propagating the exception.""" + + return getattr(self._consumed_thing.td.properties[self._name], name) + + async def read(self, timeout=None, client_kwargs=None): + """The read() method will fetch the value of the Property. + A coroutine that yields the value or raises an error.""" + + value = await self._consumed_thing.read_property( + self._name, + timeout=timeout, + client_kwargs=client_kwargs) + + return value + + async def write(self, value, timeout=None, client_kwargs=None): + """The write() method will attempt to set the value of the + Property specified in the value argument whose type SHOULD + match the one specified by the type property. + A coroutine that yields on success or raises an error.""" + + await self._consumed_thing.write_property( + self._name, value, + timeout=timeout, + client_kwargs=client_kwargs) + + def subscribe(self, *args, **kwargs): + """Subscribe to an stream of events emitted when the property value changes.""" + + client_kwargs = kwargs.pop("client_kwargs", None) + + observable = self._consumed_thing.on_property_change( + self._name, + client_kwargs=client_kwargs) + + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + kwargs["scheduler"] = scheduler + + return observable.subscribe(*args, **kwargs) + + +class ConsumedThingAction: + """The ThingAction interface implementation for ConsumedThing objects.""" + + def __init__(self, consumed_thing, name): + self._consumed_thing = consumed_thing + self._name = name + + def __str__(self): + return "<{}> ({}::{})".format(self.__class__.__name__, self._consumed_thing.id, self._name) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private init dict before propagating the exception.""" + + return getattr(self._consumed_thing.td.actions[self._name], name) + + async def invoke(self, *args, **kwargs): + """The invoke() method when invoked, starts the Action interaction + with the input value provided by the inputValue argument.""" + + input_value = args[0] if len(args) else None + client_kwargs = kwargs.pop("client_kwargs", None) + timeout = kwargs.pop("timeout", None) + + result = await self._consumed_thing.invoke_action( + self._name, input_value, + timeout=timeout, + client_kwargs=client_kwargs) + + return result + + +class ConsumedThingEvent: + """The ThingEvent interface implementation for ConsumedThing objects.""" + + def __init__(self, consumed_thing, name): + self._consumed_thing = consumed_thing + self._name = name + + def __str__(self): + return "<{}> ({}::{})".format(self.__class__.__name__, self._consumed_thing.id, self._name) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private init dict before propagating the exception.""" + + return getattr(self._consumed_thing.td.events[self._name], name) + + def subscribe(self, *args, **kwargs): + """Subscribe to an stream of emissions of this event.""" + + client_kwargs = kwargs.pop("client_kwargs", None) + + observable = self._consumed_thing.on_event( + self._name, + client_kwargs=client_kwargs) + + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + kwargs["scheduler"] = scheduler + + return observable.subscribe(*args, **kwargs) diff --git a/wotpy/wot/consumed/thing.py b/wotpy/wot/consumed/thing.py new file mode 100644 index 0000000..3a0f2e4 --- /dev/null +++ b/wotpy/wot/consumed/thing.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents a Thing consumed by a servient. +""" + +from reactivex.scheduler.eventloop import IOLoopScheduler +from tornado import ioloop + +from wotpy.wot.consumed.interaction_map import \ + ConsumedThingPropertyDict, \ + ConsumedThingActionDict, \ + ConsumedThingEventDict + + +class ConsumedThing: + """An entity that serves to interact with a Thing. + An application uses this class when it acts as a *client* of the Thing.""" + + def __init__(self, servient, td): + self._servient = servient + self._td = td + + def __str__(self): + return "<{}> {}".format(self.__class__.__name__, self.td.id) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private ThingFragment instance before propagating the exception.""" + + return getattr(self.td.to_thing_fragment(), name) + + @property + def servient(self): + """Returns the Servient that contains this Consumed Thing.""" + + return self._servient + + @property + def td(self): + """Returns the ThingDescription instance that represents + the TD that this Consumed Thing is based on.""" + + return self._td + + async def invoke_action(self, name, input_value=None, timeout=None, client_kwargs=None): + """Takes the Action name from the name argument and the list of parameters, + then requests from the underlying platform and the Protocol Bindings to invoke + the Action on the remote Thing and return the result. + Returns a Future that resolves with the return value or rejects with an Error.""" + + client = self.servient.select_client(self.td, name) + client_kwargs = client_kwargs if client_kwargs else {} + + result = await client.invoke_action( + self.td, name, input_value, + timeout=timeout, + **client_kwargs.get(client.protocol, {})) + + return result + + async def write_property(self, name, value, timeout=None, client_kwargs=None): + """Takes the Property name as the name argument and the new value as the value + argument, then requests from the underlying platform and the Protocol Bindings + to update the Property on the remote Thing and return the result. + Returns a Future that resolves on success or rejects with an Error.""" + + client = self.servient.select_client(self.td, name) + client_kwargs = client_kwargs if client_kwargs else {} + + await client.write_property( + self.td, name, value, + timeout=timeout, + **client_kwargs.get(client.protocol, {})) + + async def read_property(self, name, timeout=None, client_kwargs=None): + """Takes the Property name as the name argument, then requests from the + underlying platform and the Protocol Bindings to retrieve the Property + on the remote Thing and return the result. + Returns a Future that resolves with the Property value or rejects with an Error.""" + + client = self.servient.select_client(self.td, name) + client_kwargs = client_kwargs if client_kwargs else {} + + value = await client.read_property( + self.td, name, + timeout=timeout, + **client_kwargs.get(client.protocol, {})) + + return value + + def on_event(self, name, client_kwargs=None): + """Returns an Observable for the Event specified in the name argument, + allowing subscribing to and unsubscribing from notifications.""" + + client = self.servient.select_client(self.td, name) + client_kwargs = client_kwargs if client_kwargs else {} + + return client.on_event( + self.td, name, + **client_kwargs.get(client.protocol, {})) + + def on_property_change(self, name, client_kwargs=None): + """Returns an Observable for the Property specified in the name argument, + allowing subscribing to and unsubscribing from notifications.""" + + client = self.servient.select_client(self.td, name) + client_kwargs = client_kwargs if client_kwargs else {} + + return client.on_property_change( + self.td, name, + **client_kwargs.get(client.protocol, {})) + + def on_td_change(self): + """Returns an Observable, allowing subscribing to and unsubscribing + from notifications to the Thing Description.""" + + raise NotImplementedError() + + @property + def properties(self): + """Returns a dictionary of ThingProperty items.""" + + return ConsumedThingPropertyDict(consumed_thing=self) + + @property + def actions(self): + """Returns a dictionary of ThingAction items.""" + + return ConsumedThingActionDict(consumed_thing=self) + + @property + def events(self): + """Returns a dictionary of ThingEvent items.""" + + return ConsumedThingEventDict(consumed_thing=self) + + @property + def links(self): + """Represents a dictionary of WebLink items.""" + + raise NotImplementedError() + + def subscribe(self, *args, **kwargs): + """Subscribes to changes on the TD of this thing.""" + + observable = self.on_td_change() + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + kwargs["scheduler"] = scheduler + + return observable.subscribe(*args, **kwargs) diff --git a/wotpy/wot/dictionaries/__init__.py b/wotpy/wot/dictionaries/__init__.py new file mode 100644 index 0000000..09fb737 --- /dev/null +++ b/wotpy/wot/dictionaries/__init__.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Objects defined in the Scripting API specification represented as classes that are basically dict-wrappers. + +.. autosummary:: + :toctree: _dictionaries + + wotpy.wot.dictionaries.base + wotpy.wot.dictionaries.filter + wotpy.wot.dictionaries.interaction + wotpy.wot.dictionaries.link + wotpy.wot.dictionaries.schema + wotpy.wot.dictionaries.security + wotpy.wot.dictionaries.thing + wotpy.wot.dictionaries.version +""" diff --git a/wotpy/wot/dictionaries/base.py b/wotpy/wot/dictionaries/base.py new file mode 100644 index 0000000..994d00f --- /dev/null +++ b/wotpy/wot/dictionaries/base.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Base class for WoT dictionaries. +""" + +from wotpy.utils.utils import merge_args_kwargs_dict, to_camel, to_snake + + +class WotBaseDict: + """Base class for all WoT data types represented + as dictionaries in the Scripting API specification.""" + + class Meta: + fields = set() + required = set() + defaults = dict() + + def __init__(self, *args, **kwargs): + """Constructor. + Will raise ValueError if there is some required field missing.""" + + init_dict = merge_args_kwargs_dict(args, kwargs) + + self._init = {} + + for key, val in init_dict.items(): + self._init.update({to_camel(key): val}) + + try: + required = self.Meta.required + except AttributeError: + required = [] + + for field in required: + if field not in self._init: + raise ValueError("Missing required field: {}".format(field)) + + def __getattr__(self, name): + """Transforms the field name to camelCase and + attemps to retrieve it from the internal dict.""" + + name_camel = to_camel(name) + + if name_camel not in self.Meta.fields: + raise AttributeError(name) + + if name_camel in self._init: + return self._init[name_camel] + + try: + return self.Meta.defaults.get(name_camel, None) + except AttributeError: + return None + + def to_dict(self): + """Returns the pure dict (JSON-serializable) representation of this WoT dictionary.""" + + ret = {} + + def is_list_wot_dicts(x): + return isinstance(x, list) and len(x) and hasattr(x[0], "to_dict") + + def is_dict_wot_dicts(x): + return isinstance(x, dict) and len(x) and hasattr(next(iter(x.values())), "to_dict") + + def is_wot_dict(x): + return hasattr(x, "to_dict") + + existing_fields = [ + f for f in self.Meta.fields + if f in self._init or (to_snake(f) in dir(self) and getattr(self, to_snake(f)) is not None) + ] + + for name_camel in existing_fields: + field_val = getattr(self, to_snake(name_camel)) + + if is_list_wot_dicts(field_val): + field_val = [item.to_dict() for item in field_val] + elif is_dict_wot_dicts(field_val): + field_val = {key: val.to_dict() for key, val in field_val.items()} + elif is_wot_dict(field_val): + field_val = field_val.to_dict() + + ret.update({name_camel: field_val}) + + return ret diff --git a/wotpy/wot/dictionaries/filter.py b/wotpy/wot/dictionaries/filter.py new file mode 100644 index 0000000..a262333 --- /dev/null +++ b/wotpy/wot/dictionaries/filter.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper class for dictionaries to represent Thing filters. +""" + +from wotpy.wot.dictionaries.base import WotBaseDict +from wotpy.wot.enums import DiscoveryMethod + + +class ThingFilterDict(WotBaseDict): + """The ThingFilter dictionary that represents the + constraints for discovering Things as key-value pairs.""" + + class Meta: + fields = { + "method", + "url", + "query", + "fragment" + } + + defaults = { + "method": DiscoveryMethod.ANY + } diff --git a/wotpy/wot/dictionaries/interaction.py b/wotpy/wot/dictionaries/interaction.py new file mode 100644 index 0000000..6dac83a --- /dev/null +++ b/wotpy/wot/dictionaries/interaction.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper classes for dictionaries for interaction initialization that are defined in the Scripting API. +""" + +from wotpy.wot.dictionaries.base import WotBaseDict +from wotpy.wot.dictionaries.link import FormDict +from wotpy.wot.dictionaries.schema import DataSchemaDict +from wotpy.wot.dictionaries.security import SecuritySchemeDict + + +class InteractionFragmentDict(WotBaseDict): + """Base class for the three types of Interaction patterns + (Properties, Actions and Events).""" + + class Meta: + fields = { + "@type", + "title", + "titles", + "description", + "descriptions", + "forms", + "uriVariables" + } + + @property + def forms(self): + """Indicates one or more endpoints from which + an interaction pattern is accessible.""" + + return [FormDict(item) for item in self._init.get("forms", [])] + + @property + def uri_variables(self): + """Define URI template variables as collection based on DataSchema declarations.""" + + if "uriVariables" not in self._init: + return None + + return { + key: DataSchemaDict.build(val) + for key, val in self._init.get("uriVariables").items() + } + + +class PropertyFragmentDict(InteractionFragmentDict): + """A dictionary wrapper class that contains data to initialize a Property.""" + + class Meta: + fields = InteractionFragmentDict.Meta.fields.union({ + "observable" + }) + + def __init__(self, *args, **kwargs): + super(PropertyFragmentDict, self).__init__(*args, **kwargs) + self._data_schema = DataSchemaDict.build(self._init) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the internal ValueType before propagating the exception.""" + + try: + return super(PropertyFragmentDict, self).__getattr__(name) + except AttributeError: + return getattr(self.data_schema, name) + + def to_dict(self): + """Returns the pure dict (JSON-serializable) representation of this WoT dictionary.""" + + ret = super(PropertyFragmentDict, self).to_dict() + ret.update(self.data_schema.to_dict()) + + return ret + + @property + def data_schema(self): + """The DataSchema that represents the schema of this property.""" + + return self._data_schema + + @property + def writable(self): + """Returns True if this Property is writable.""" + + return not self.data_schema.read_only + + @property + def forms(self): + """Indicates one or more endpoints from which + an interaction pattern is accessible.""" + + form_dicts = [FormDict(item) for item in self._init.get("forms", [])] + for form_dict in form_dicts: + read_only = bool(self._init.get("readOnly")) + write_only = bool(self._init.get("writeOnly")) + + if not read_only and not write_only: + form_dict.Meta.defaults["op"] = [ + "readproperty", + "writeproperty" + ] + elif read_only: + form_dict.Meta.defaults["op"] = ["readproperty"] + elif write_only: + form_dict.Meta.defaults["op"] = ["writeproperty"] + + return form_dicts + + +class ActionFragmentDict(InteractionFragmentDict): + """A dictionary wrapper class that contains data to initialize an Action.""" + + class Meta: + fields = InteractionFragmentDict.Meta.fields.union({ + "input", + "output", + "safe", + "idempotent", + "synchronous" + }) + + defaults = { + "safe": False, + "idempotent": False + } + + @property + def input(self): + """Used to define the input data schema of the action.""" + + init = self._init.get("input") + + return DataSchemaDict.build(init) if init else None + + @property + def output(self): + """Used to define the output data schema of the action.""" + + init = self._init.get("output") + + return DataSchemaDict.build(init) if init else None + + @property + def forms(self): + """Indicates one or more endpoints from which + an interaction pattern is accessible.""" + + form_dicts = [FormDict(item) for item in self._init.get("forms", [])] + for form_dict in form_dicts: + form_dict.Meta.defaults["op"] = "invokeaction" + return form_dicts + + +class EventFragmentDict(InteractionFragmentDict): + """A dictionary wrapper class that contains data to initialize an Event.""" + + class Meta: + fields = InteractionFragmentDict.Meta.fields.union({ + "subscription", + "data", + "dataResponse", + "cancellation" + }) + + @property + def subscription(self): + """Defines data that needs to be passed upon subscription, + e.g., filters or message format for setting up Webhooks.""" + + init = self._init.get("subscription") + + return DataSchemaDict.build(init) if init else None + + @property + def data(self): + """Defines the data schema of the Event instance messages pushed by the Thing.""" + + init = self._init.get("data") + + return DataSchemaDict.build(init) if init else None + + @property + def data_response(self): + """Defines the data schema of the Event response messages sent by the + consumer in a response to a data message.""" + + init = self._init.get("dataResponse") + + return DataSchemaDict.build(init) if init else None + + @property + def cancellation(self): + """Defines any data that needs to be passed to cancel a subscription, + e.g., a specific message to remove a Webhook.""" + + init = self._init.get("cancellation") + + return DataSchemaDict.build(init) if init else None + + @property + def forms(self): + """Indicates one or more endpoints from which + an interaction pattern is accessible.""" + + form_dicts = [FormDict(item) for item in self._init.get("forms", [])] + for form_dict in form_dicts: + form_dict.Meta.defaults["op"] = [ + "subscribeevent", + "unsubscribeevent" + ] + return form_dicts diff --git a/wotpy/wot/dictionaries/link.py b/wotpy/wot/dictionaries/link.py new file mode 100644 index 0000000..71a5013 --- /dev/null +++ b/wotpy/wot/dictionaries/link.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper classes for link dictionaries defined in the Scripting API. +""" + +import urllib + +from wotpy.wot.dictionaries.base import WotBaseDict +from wotpy.wot.dictionaries.security import SecuritySchemeDict +from wotpy.wot.dictionaries.response import ExpectedResponse, AdditionalExpectedResponse + + +class LinkDict(WotBaseDict): + """A Web link, as specified by IETF RFC 8288.""" + + class Meta: + fields = { + "href", + "type", + "rel", + "anchor", + "sizes", + "hreflang" + } + + required = { + "href" + } + + +class FormDict(WotBaseDict): + """Communication metadata indicating where a service can be accessed + by a client application. An interaction might have more than one form.""" + + class Meta: + fields = { + "href", + "contentType", + "contentCoding", + "security", + "scopes", + "response", + "additionalResponses", + "subprotocol", + "op" + } + + required = { + "href" + } + + defaults = { + "contentType": "application/json" + } + + @property + def response(self): + """This optional term can be used if the output communication + metadata differ from input metadata.""" + + return ExpectedResponse(self._init.get("response")) if self._init.get("response") else None + + @property + def additional_responses(self): + """This optional term can be used if additional expected + responses are possible, e.g. for error reporting. Each + additional response needs to be distinguished from others + in some way (for example, by specifying a protocol-specific + error code), and may also have its own data schema.""" + + return [AdditionalExpectedResponse(item) for item in self._init.get("additionalResponses", [])] + + + def resolve_uri(self, base=None): + """Resolves and returns the Link URI. + When the href does not contain a full URL the base URI is joined with said href.""" + + href_parsed = urllib.parse.urlparse(self.href) + + if base and not href_parsed.scheme: + return urllib.parse.urljoin(base, self.href) + + if href_parsed.scheme: + return self.href + + return None diff --git a/wotpy/wot/dictionaries/response.py b/wotpy/wot/dictionaries/response.py new file mode 100644 index 0000000..54bf478 --- /dev/null +++ b/wotpy/wot/dictionaries/response.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper classes for expected responses. +""" + +from wotpy.wot.dictionaries.base import WotBaseDict + + +class ExpectedResponse(WotBaseDict): + """Communication metadata describing the expected response + message for the primary response.""" + + class Meta: + fields = { + "contentType" + } + + required = { + "contentType" + } + + +class AdditionalExpectedResponse(WotBaseDict): + """Communication metadata describing the expected response + message for additional responses.""" + + class Meta: + fields = { + "success", + "contentType", + "schema" + } + + required = { + "contentType" + } + + defaults = { + "success": False + } diff --git a/wotpy/wot/dictionaries/schema.py b/wotpy/wot/dictionaries/schema.py new file mode 100644 index 0000000..769cf61 --- /dev/null +++ b/wotpy/wot/dictionaries/schema.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper classes for data schema dictionaries defined in the Scripting API. +""" + +from wotpy.wot.dictionaries.base import WotBaseDict +from wotpy.utils.utils import merge_args_kwargs_dict +from wotpy.wot.enums import DataType + + +class DataSchemaDict(WotBaseDict): + """Represents the common properties of a value type definition.""" + + class Meta: + fields = { + "@type", + "title", + "titles", + "description", + "descriptions", + "const", + "default", + "unit", + "oneOf", + "enum", + "readOnly", + "writeOnly", + "format", + "type" + } + + defaults = { + "readOnly": False, + "writeOnly": False + } + + @property + def one_of(self): + """Used to ensure that the data is valid against + one of the specified schemas in the array.""" + + return [DataSchemaDict.build(item) for item in self._init.get("oneOf", [])] + + @classmethod + def build(cls, *args, **kwargs): + """Builds an instance of the appropriate subclass for the given ValueType.""" + + init_dict = merge_args_kwargs_dict(args, kwargs) + + klass_map = { + DataType.NUMBER: NumberSchemaDict, + DataType.BOOLEAN: BooleanSchemaDict, + DataType.STRING: StringSchemaDict, + DataType.OBJECT: ObjectSchemaDict, + DataType.ARRAY: ArraySchemaDict, + DataType.INTEGER: IntegerSchema + } + + klass_type = init_dict.get("type") + klass = klass_map.get(klass_type) + + if not klass: + raise ValueError("Unknown type: {}".format(klass_type)) + + return klass(*args, **kwargs) + + +class NumberSchemaDict(DataSchemaDict): + """Properties to describe a numeric type.""" + + class Meta: + fields = DataSchemaDict.Meta.fields.union({ + "minimum", + "exclusiveMinimum", + "maximum", + "exclusiveMaximum", + "multipleOf" + }) + + defaults = DataSchemaDict.Meta.defaults + + @property + def type(self): + """The type property represents the value type (a member of DataType).""" + + return DataType.NUMBER + + +class BooleanSchemaDict(DataSchemaDict): + """Properties to describe a boolean type.""" + + @property + def type(self): + """The type property represents the value type enumerated in DataType.""" + + return DataType.BOOLEAN + + +class StringSchemaDict(DataSchemaDict): + """Properties to describe a string type.""" + + class Meta: + fields = DataSchemaDict.Meta.fields.union({ + "minLength", + "maxLength", + "pattern", + "contentEncoding", + "contentMediaType" + }) + + defaults = DataSchemaDict.Meta.defaults + + @property + def type(self): + """The type property represents the value type enumerated in DataType.""" + + return DataType.STRING + + +class ObjectSchemaDict(DataSchemaDict): + """Properties to describe an object type.""" + + class Meta: + fields = DataSchemaDict.Meta.fields.union({ + "properties", + "required" + }) + + defaults = DataSchemaDict.Meta.defaults + + @property + def type(self): + """The type property represents the value type enumerated in DataType.""" + + return DataType.OBJECT + + @property + def properties(self): + """Data schema nested definitions.""" + + return { + key: DataSchemaDict.build(val) + for key, val in self._init.get("properties", {}).items() + } + + +class ArraySchemaDict(DataSchemaDict): + """Properties to describe an array type.""" + + class Meta: + fields = DataSchemaDict.Meta.fields.union({ + "items", + "minItems", + "maxItems" + }) + + defaults = DataSchemaDict.Meta.defaults + + @property + def type(self): + """The type property represents the value type enumerated in DataType.""" + + return DataType.ARRAY + + @property + def items(self): + """Used to define the characteristics of an array.""" + + return DataSchemaDict.build(self._init["items"]) if "items" in self._init else None + + +class IntegerSchema(NumberSchemaDict): + """Properties to describe an integer type.""" + + @property + def type(self): + """The type property represents the value type enumerated in DataType.""" + + return DataType.INTEGER diff --git a/wotpy/wot/dictionaries/security.py b/wotpy/wot/dictionaries/security.py new file mode 100644 index 0000000..b378c19 --- /dev/null +++ b/wotpy/wot/dictionaries/security.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper classes for security dictionaries defined in the Scripting API. +""" + +from wotpy.wot.dictionaries.base import WotBaseDict +from wotpy.utils.utils import merge_args_kwargs_dict +from wotpy.wot.enums import SecuritySchemeType + + +class SecuritySchemeDict(WotBaseDict): + """Contains security related configuration.""" + + class Meta: + fields = { + "@type", + "description", + "descriptions", + "proxy", + "scheme" + } + + required = { + "scheme" + } + + @classmethod + def build(cls, *args, **kwargs): + """Builds an instance of the appropriate subclass for the given SecurityScheme.""" + + init_dict = merge_args_kwargs_dict(args, kwargs) + + klass_map = { + SecuritySchemeType.NOSEC: NoSecuritySchemeDict, + SecuritySchemeType.AUTO: AutoSecuritySchemeDict, + SecuritySchemeType.COMBO: ComboSecuritySchemeDict, + SecuritySchemeType.BASIC: BasicSecuritySchemeDict, + SecuritySchemeType.DIGEST: DigestSecuritySchemeDict, + SecuritySchemeType.APIKEY: APIKeySecuritySchemeDict, + SecuritySchemeType.BEARER: BearerSecuritySchemeDict, + SecuritySchemeType.PSK: PSKSecuritySchemeDict, + SecuritySchemeType.OAUTH2: OAuth2SecuritySchemeDict + } + + scheme_type = init_dict.get("scheme") + klass = klass_map.get(scheme_type) + + if not klass: + raise ValueError("Unknown scheme: {}".format(scheme_type)) + + return klass(*args, **kwargs) + + +class NoSecuritySchemeDict(SecuritySchemeDict): + """A security configuration indicating there is no authentication + or other mechanism required to access the resource.""" + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.NOSEC + + +class AutoSecuritySchemeDict(SecuritySchemeDict): + """An automatic authentication security configuration indicating + that the security parameters are going to be negotiated by + the underlying protocols at runtime.""" + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.AUTO + + +class ComboSecuritySchemeDict(SecuritySchemeDict): + """A combination of other security schemes. Elements of this scheme define + various ways in which other named schemes defined in securityDefinitions, + including other ComboSecurityScheme definitions, are to be combined to + create a new scheme definition.""" + + class Meta: + fields = SecuritySchemeDict.Meta.fields.union({ + "oneOf", + "allOf" + }) + + required = SecuritySchemeDict.Meta.required + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.COMBO + + +class BasicSecuritySchemeDict(SecuritySchemeDict): + """Basic authentication security configuration using an unencrypted username and password.""" + + class Meta: + fields = SecuritySchemeDict.Meta.fields.union({ + "in", + "name" + }) + + required = SecuritySchemeDict.Meta.required + + defaults = { + "in": "header" + } + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.BASIC + + +class DigestSecuritySchemeDict(SecuritySchemeDict): + """Digest authentication security configuration. This scheme is similar to + basic authentication but with added features to avoid man-in-the-middle attacks.""" + + class Meta: + fields = SecuritySchemeDict.Meta.fields.union({ + "qop", + "in", + "name" + }) + + required = SecuritySchemeDict.Meta.required + + defaults = { + "qop": "auth", + "in": "header" + } + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.DIGEST + + +class APIKeySecuritySchemeDict(SecuritySchemeDict): + """API key authentication security configuration. + This is for the case where the access token is opaque and is not using a standard token format.""" + + class Meta: + fields = SecuritySchemeDict.Meta.fields.union({ + "in", + "name" + }) + + required = SecuritySchemeDict.Meta.required + + defaults = { + "in": "query" + } + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.APIKEY + + +class BearerSecuritySchemeDict(SecuritySchemeDict): + """Bearer token authentication security configuration. This scheme is intended + for situations where bearer tokens are used independently of OAuth2. + If the oauth2 scheme is specified it is not generally necessary to + specify this scheme as well as it is implied.""" + + class Meta: + fields = SecuritySchemeDict.Meta.fields.union({ + "authorization", + "alg", + "format", + "in", + "name" + }) + + required = SecuritySchemeDict.Meta.required + + defaults = { + "alg": "ES256", + "format": "jwt", + "in": "header" + } + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.BEARER + + +class PSKSecuritySchemeDict(SecuritySchemeDict): + """Pre-shared key authentication security configuration.""" + + class Meta: + fields = SecuritySchemeDict.Meta.fields.union({ + "identity" + }) + + required = SecuritySchemeDict.Meta.required + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.PSK + + +class OAuth2SecuritySchemeDict(SecuritySchemeDict): + """OAuth2 authentication security configuration. + For the implicit flow the authorization and scopes are required. + For the password and client flows both token and scopes are required. + For the code flow authorization, token, and scopes are required.""" + + class Meta: + fields = SecuritySchemeDict.Meta.fields.union({ + "authorization", + "token", + "refresh", + "scopes", + "flow" + }) + + required = SecuritySchemeDict.Meta.required + + defaults = { + "flow": "implicit" + } + + @property + def scheme(self): + """The scheme property represents the identification + of the security scheme to be used for the Thing.""" + + return SecuritySchemeType.OAUTH2 diff --git a/wotpy/wot/dictionaries/thing.py b/wotpy/wot/dictionaries/thing.py new file mode 100644 index 0000000..560fe0f --- /dev/null +++ b/wotpy/wot/dictionaries/thing.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper class for dictionaries to represent Things. +""" + +from wotpy.wot.dictionaries.base import WotBaseDict +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.dictionaries.link import LinkDict +from wotpy.wot.dictionaries.security import SecuritySchemeDict +from wotpy.utils.utils import to_camel +from wotpy.wot.dictionaries.version import VersioningDict +from wotpy.wot.enums import SecuritySchemeType + + +class ThingFragment(WotBaseDict): + """ThingFragment is a wrapper around a dictionary that contains properties + representing semantic metadata and interactions (Properties, Actions and Events). + It is used for initializing an internal representation of a Thing Description, + and it is also used in ThingFilter.""" + + class Meta: + fields = { + "@context", + "@type", + "id", + "title", + "titles", + "description", + "descriptions", + "version", + "created", + "modified", + "support", + "base", + "properties", + "actions", + "events", + "links", + "forms", + "security", + "securityDefinitions", + "profile", + "schemaDefinitions", + "uriVariables" + } + + required = { + "@context", + "title", + "security", + "securityDefinitions" + } + + fields_readonly = [ + "title" + ] + + fields_str = [ + "@context", + "@type", + "id", + "title", + "description", + "created", + "modified", + "support", + "base", + "security", + "profile" + ] + + fields_dict = [ + "titles", + "descriptions", + "properties", + "actions", + "events", + "securityDefinitions", + "schemaDefinitions", + "uriVariables" + ] + + fields_list = [ + "@context", + "@type", + "links", + "forms", + "security", + "profile" + ] + + fields_instance = [ + "version" + ] + + assert set(fields_readonly + fields_str + fields_dict + fields_list + fields_instance) == fields + + def __setattr__(self, name, value): + """Checks to see if the attribute that is being set is a + Thing fragment property and updates the internal dict.""" + + name_camel = to_camel(name) + + if name_camel not in self.Meta.fields: + return super(ThingFragment, self).__setattr__(name, value) + + if name_camel in self.Meta.fields_readonly: + raise AttributeError("Can't set attribute {}".format(name)) + + if name_camel in self.Meta.fields_str: + self._init[name_camel] = value + return + + if name_camel in self.Meta.fields_dict: + self._init[name_camel] = {key: val.to_dict() for key, val in value.items()} + return + + if name_camel in self.Meta.fields_list: + self._init[name_camel] = [item.to_dict() for item in value] + return + + if name_camel in self.Meta.fields_instance: + self._init[name_camel] = value.to_dict() + return + + @property + def title(self): + """The title of the Thing.""" + + return self._init.get("title") + + @property + def security(self): + """Set of security configurations, provided as an array, + that must all be satisfied for access to resources at or + below the current level, if not overridden at a lower level.""" + + return self._init.get("security") + + @property + def security_definitions(self): + return { + key: SecuritySchemeDict.build(val) + for key, val in self._init.get("securityDefinitions", {}).items() + } + + @property + def properties(self): + """The properties optional attribute represents a dict with keys + that correspond to Property names and values of type PropertyFragment.""" + + return { + key: PropertyFragmentDict(val) + for key, val in self._init.get("properties", {}).items() + } + + @property + def actions(self): + """The actions optional attribute represents a dict with keys + that correspond to Action names and values of type ActionFragment.""" + + return { + key: ActionFragmentDict(val) + for key, val in self._init.get("actions", {}).items() + } + + @property + def events(self): + """The events optional attribute represents a dictionary with keys + that correspond to Event names and values of type EventFragment.""" + + return { + key: EventFragmentDict(val) + for key, val in self._init.get("events", {}).items() + } + + @property + def links(self): + """The links optional attribute represents an array of Link objects.""" + + return [LinkDict(item) for item in self._init.get("links", [])] + + @property + def version(self): + """Provides version information.""" + + return VersioningDict(self._init.get("version")) if self._init.get("version") else None + + @property + def schema_definitons(self): + """Set of named data schemas. To be used in a schema name-value pair + inside an AdditionalExpectedResponse object.""" + + if "schemaDefinitions" not in self._init: + return None + + return { + key: DataSchemaDict.build(val) + for key, val in self._init.get("schemaDefinitions").items() + } + + @property + def uri_variables(self): + """Define URI template variables as collection based on DataSchema declarations.""" + + if "uriVariables" not in self._init: + return None + + return { + key: DataSchemaDict.build(val) + for key, val in self._init.get("uriVariables").items() + } \ No newline at end of file diff --git a/wotpy/wot/dictionaries/version.py b/wotpy/wot/dictionaries/version.py new file mode 100644 index 0000000..c3461fa --- /dev/null +++ b/wotpy/wot/dictionaries/version.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Wrapper classes for versioning dictionaries defined in the Scripting API. +""" + +from wotpy.wot.dictionaries.base import WotBaseDict + + +class VersioningDict(WotBaseDict): + """Carries version information about the TD instance. + If required, additional version information such as firmware and hardware version + (term definitions outside of the TD namespace) can be extended here.""" + + class Meta: + fields = { + "instance", + "model" + } + + required = { + "instance" + } diff --git a/wotpy/wot/discovery/__init__.py b/wotpy/wot/discovery/__init__.py new file mode 100644 index 0000000..eb335e1 --- /dev/null +++ b/wotpy/wot/discovery/__init__.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Thing discovery services. + +.. autosummary:: + :toctree: _discovery + + wotpy.wot.discovery.dnssd +""" diff --git a/wotpy/wot/discovery/dnssd/__init__.py b/wotpy/wot/discovery/dnssd/__init__.py new file mode 100644 index 0000000..7a891bf --- /dev/null +++ b/wotpy/wot/discovery/dnssd/__init__.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +DNS Service Discovery (based on Multicast DNS) Thing discovery service. + +.. autosummary:: + :toctree: _dnssd + + wotpy.wot.discovery.dnssd.service +""" + +from wotpy.support import is_dnssd_supported + +if is_dnssd_supported() is False: + raise NotImplementedError("DNS-SD is not supported in this platform") diff --git a/wotpy/wot/discovery/dnssd/service.py b/wotpy/wot/discovery/dnssd/service.py new file mode 100644 index 0000000..cfbf825 --- /dev/null +++ b/wotpy/wot/discovery/dnssd/service.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Service discovery based on Multicast DNS and DNS-SD (Bonjour, Avahi). +""" + +import asyncio +import queue +import socket +import threading +import time +from functools import partial +from typing import cast + +from slugify import slugify +from zeroconf import IPVersion, Zeroconf, ServiceStateChange, ServiceInfo, ServiceBrowser + +from wotpy.utils.utils import get_main_ipv4_address + +ITER_WAIT = 0.1 +TASK_REGISTER = 'register' +TASK_UNREGISTER = 'unregister' + + +def build_servient_service_info(servient, address=None, instance_name=None): + """Takes a Servient and optional IP address and builds the + zeroconf ServiceInfo that describes the WoT Servient service.""" + + address = address if address else get_main_ipv4_address() + servient_urlname = slugify(servient.hostname) + instance_name = instance_name if instance_name else servient_urlname + servient_fqdn = "{}.".format(servient.hostname.strip('.')) + server_default = "{}.local.".format(servient_urlname) + server = servient_fqdn if servient_fqdn.endswith( + '.local.') else server_default + + return ServiceInfo( + DNSSDDiscoveryService.WOT_SERVICE_TYPE, + "{}.{}".format(instance_name, DNSSDDiscoveryService.WOT_SERVICE_TYPE), + port=servient.catalogue_port, + addresses=[socket.inet_aton(address)], + properties={}, + server=server) + + +def _start_zeroconf(close_event, services, services_lock, register_queue, address): + """Starts the zeroconf mDNS service. + Listens to the register tasks queue and starts browsing for WoT Servient services.""" + + address = address if address else get_main_ipv4_address() + zeroconf = Zeroconf() + registered = [] + registered_lock = threading.Lock() + + def _on_service_change(*args, **kwargs): + """Callback for each time a WoT Servient service is added or removed from the link.""" + + service_type = kwargs.pop('service_type') + name = kwargs.pop('name') + state_change = kwargs.pop('state_change') + + with registered_lock: + is_local = any(item.name == name for item in registered) + + if is_local: + return + + def _add_result(): + info = zeroconf.get_service_info(service_type, name) + if not info: + raise TimeoutError("zeroconf: no matches for the service info") + + info_addr = info.parsed_addresses(IPVersion.V4Only)[0] + info_port = cast(int, info.port) + + with services_lock: + services[name] = (info_addr, info_port) + + def _remove_result(): + with services_lock: + try: + services.pop(name) + except KeyError: + pass + + def _update_result(): + """Make the update function behave in the same way as the add function""" + _add_result() + + + change_handler_map = { + ServiceStateChange.Added: _add_result, + ServiceStateChange.Removed: _remove_result, + ServiceStateChange.Updated: _update_result + } + + change_handler_map[state_change]() + + def _register(task): + """Registers a new WoT Servient service.""" + + servient = task['servient'] + done = task['done'] + instance_name = task['instance_name'] + + try: + if not servient.catalogue_port: + return + + info = build_servient_service_info( + servient, + address=address, + instance_name=instance_name) + + with registered_lock: + registered.append(info) + + zeroconf.register_service(info) + finally: + done.set() + + def _unregister(task): + """Unregisters a WoT Servient service.""" + + servient = task['servient'] + done = task['done'] + instance_name = task['instance_name'] + + try: + info = build_servient_service_info( + servient, + address=address, + instance_name=instance_name) + + with registered_lock: + is_registered = any(val == info for val in registered) + + if not is_registered: + return + + zeroconf.unregister_service(info) + + with registered_lock: + registered.remove(info) + finally: + done.set() + + def _main_loop(): + """Main Zeroconf service loop that starts browsing for WoT + services and processes the register tasks queue.""" + + browser = None + + try: + browser = ServiceBrowser( + zeroconf, + DNSSDDiscoveryService.WOT_SERVICE_TYPE, + handlers=[_on_service_change]) + + while not close_event.is_set(): + try: + register_task = register_queue.get_nowait() + + task_handler_map = { + TASK_REGISTER: _register, + TASK_UNREGISTER: _unregister + } + + task_handler_map[register_task['type']](register_task) + except queue.Empty: + pass + + close_event.wait(ITER_WAIT) + finally: + if browser is not None: + browser.cancel() + + with registered_lock: + for serv_info in registered: + zeroconf.unregister_service(serv_info) + + zeroconf.close() + + _main_loop() + + +class DNSSDDiscoveryService: + """Manages a DNS Service Discovery service (based on Multicast DNS) + that is run on a separate thread (on a loop executor) to discover + link-local WoT Servients and expose its own.""" + + WOT_SERVICE_TYPE = "_wot-servient._tcp.local." + + def __init__(self, address=None): + self._address = address + self._zeroconf_loop_thread = None + self._close_event = threading.Event() + self._register_queue = None + self._lock = asyncio.Lock() + self._services = None + self._services_lock = threading.Lock() + + @property + def is_running(self): + """Returns True if the mDNS service is currently running.""" + + return self._zeroconf_loop_thread is not None + + async def start(self): + """Starts the DNS-SD thread on a loop executor.""" + + async with self._lock: + if self._zeroconf_loop_thread is not None: + return + + self._close_event.clear() + self._register_queue = queue.Queue() + self._services = {} + + thread_target = partial( + _start_zeroconf, + self._close_event, + self._services, + self._services_lock, + self._register_queue, + self._address) + + self._zeroconf_loop_thread = threading.Thread( + target=thread_target, + daemon=True) + + self._zeroconf_loop_thread.start() + + async def stop(self): + """Signals the DNS-SD thread to stop and waits for the executor future to yield.""" + + async with self._lock: + if self._zeroconf_loop_thread is None: + return + + self._close_event.set() + + while self._zeroconf_loop_thread.is_alive(): + await asyncio.sleep(ITER_WAIT) + + self._zeroconf_loop_thread = None + self._close_event.clear() + self._register_queue = None + self._services = None + + async def _run_task(self, task): + """""" + + async with self._lock: + if self._zeroconf_loop_thread is None: + raise ValueError("Stopped DNS-SD thread") + + done = threading.Event() + task.update({'done': done}) + + self._register_queue.put(task) + + while not done.is_set(): + await asyncio.sleep(ITER_WAIT) + + async def register(self, servient, instance_name=None): + """Takes a Servient and registers the TD catalogue + service for discovery by other hosts in the same link.""" + + if instance_name and instance_name.endswith('.'): + raise ValueError('Instance name ends with "."') + + await self._run_task({ + 'type': TASK_REGISTER, + 'servient': servient, + 'instance_name': instance_name + }) + + async def unregister(self, servient, instance_name=None): + """Takes a Servient and unregisters the TD catalogue service.""" + + if instance_name and instance_name.endswith('.'): + raise ValueError('Instance name ends with "."') + + await self._run_task({ + 'type': TASK_UNREGISTER, + 'servient': servient, + 'instance_name': instance_name + }) + + async def find(self, min_results=None, timeout=5): + """Browses the link to discover WoT Servient services using mDNS. + Returns a list of (ip_address, port). + If min_results is defined it will stop as soon as that number of results are found.""" + + async with self._lock: + if self._zeroconf_loop_thread is None: + raise ValueError("Stopped DNS-SD thread") + + ini = time.time() + finished = False + + while (time.time() - ini) < timeout and not finished: + if min_results is not None: + with self._services_lock: + if len(self._services) >= min_results: + finished = True + + await asyncio.sleep(ITER_WAIT) + + with self._services_lock: + found = list(self._services.values()) + + return found diff --git a/wotpy/wot/enums.py b/wotpy/wot/enums.py new file mode 100644 index 0000000..f3f7270 --- /dev/null +++ b/wotpy/wot/enums.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that contain various enumerations. +""" + +from wotpy.utils.enums import EnumListMixin + + +class DiscoveryMethod(EnumListMixin): + """Enumeration of discovery types.""" + + ANY = "any" + LOCAL = "local" + DIRECTORY = "directory" + MULTICAST = "multicast" + + +class TDChangeType(EnumListMixin): + """Represents the change type, whether has it been + applied on properties, Actions or Events.""" + + PROPERTY = "property" + ACTION = "action" + EVENT = "event" + + +class TDChangeMethod(EnumListMixin): + """This attribute tells what operation has been + applied to the TD: addition, removal or change.""" + + ADD = "add" + REMOVE = "remove" + CHANGE = "change" + + +class DefaultThingEvent(EnumListMixin): + """Enumeration for the default events + that are supported on all ExposedThings.""" + + PROPERTY_CHANGE = "propertychange" + ACTION_INVOCATION = "actioninvocation" + DESCRIPTION_CHANGE = "descriptionchange" + + +class DataType(EnumListMixin): + """Defines the types that values can take.""" + + BOOLEAN = "boolean" + INTEGER = "integer" + NUMBER = "number" + STRING = "string" + OBJECT = "object" + ARRAY = "array" + NULL = "null" + + +class SecuritySchemeType(EnumListMixin): + """Defines the supported security schemes.""" + + NOSEC = "nosec" + AUTO = "auto" + COMBO = "combo" + BASIC = "basic" + DIGEST = "digest" + APIKEY = "apikey" + BEARER = "bearer" + PSK = "psk" + OAUTH2 = "oauth2" + + +class InteractionTypes(EnumListMixin): + """Enumeration of interaction types.""" + + PROPERTY = "Property" + ACTION = "Action" + EVENT = "Event" diff --git a/wotpy/wot/events.py b/wotpy/wot/events.py new file mode 100644 index 0000000..ba4f291 --- /dev/null +++ b/wotpy/wot/events.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that represent events that are emitted by Things. +""" + +import pprint + +from wotpy.wot.enums import DefaultThingEvent, TDChangeType, TDChangeMethod + + +class EmittedEvent: + """Base event class. + Represents a generic event defined in a TD.""" + + def __init__(self, init, name): + self.init = init + self.name = name + + def __str__(self): + try: + init = pprint.pformat(vars(self.init)) + except TypeError: + init = self.init + + return "<{}> {} {}".format(self.__class__.__name__, self.name, init) + + @property + def data(self): + """Data property.""" + + return self.init + + +class PropertyChangeEmittedEvent(EmittedEvent): + """Event triggered to indicate a property change. + Should be initialized with a PropertyChangeEventInit instance.""" + + def __init__(self, init): + name = DefaultThingEvent.PROPERTY_CHANGE + super(PropertyChangeEmittedEvent, self).__init__(init=init, name=name) + + +class ActionInvocationEmittedEvent(EmittedEvent): + """Event triggered to indicate an action invocation. + Should be initialized with a ActionInvocationEventInit instance.""" + + def __init__(self, init): + name = DefaultThingEvent.ACTION_INVOCATION + super(ActionInvocationEmittedEvent, self).__init__(init=init, name=name) + + +class ThingDescriptionChangeEmittedEvent(EmittedEvent): + """Event triggered to indicate a thing description change. + Should be initialized with a ThingDescriptionChangeEventInit instance.""" + + def __init__(self, init): + name = DefaultThingEvent.DESCRIPTION_CHANGE + super(ThingDescriptionChangeEmittedEvent, self).__init__(init=init, name=name) + + +class PropertyChangeEventInit: + """Represents the data contained in a property update event. + + Args: + name (str): Name of the property. + value: Value of the property. + """ + + def __init__(self, name, value): + self.name = name + self.value = value + + +class ActionInvocationEventInit: + """Represents the data contained in an action invocation event. + + Args: + action_name (str): Name of the property. + return_value: Result returned by the action invocation. + """ + + def __init__(self, action_name, return_value): + self.action_name = action_name + self.return_value = return_value + + +class ThingDescriptionChangeEventInit: + """Represents the data contained in a thing description update event. + + Args: + td_change_type (str): An item of enumeration :py:class:`.TDChangeType`. + method (str): An item of enumeration :py:class:`.TDChangeMethod`. + name (str): Name of the Interaction. + data: An instance of :py:class:`.ThingPropertyInit`, :py:class:`.ThingActionInit` + or :py:class:`.ThingEventInit` (or ``None`` if the change did not add a new interaction). + description (dict): A dict that represents a TD serialized to JSON-LD. + """ + + def __init__(self, td_change_type, method, name, data=None, description=None): + assert td_change_type in TDChangeType.list() + assert method in TDChangeMethod.list() + + self.td_change_type = td_change_type + self.method = method + self.name = name + self.data = data + self.description = description diff --git a/wotpy/wot/exposed/__init__.py b/wotpy/wot/exposed/__init__.py new file mode 100644 index 0000000..15becce --- /dev/null +++ b/wotpy/wot/exposed/__init__.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +ExposedThing and related entities. + +.. autosummary:: + :toctree: _exposed + + wotpy.wot.exposed.interaction_map + wotpy.wot.exposed.thing + wotpy.wot.exposed.thing_set +""" diff --git a/wotpy/wot/exposed/interaction_map.py b/wotpy/wot/exposed/interaction_map.py new file mode 100644 index 0000000..2ff8317 --- /dev/null +++ b/wotpy/wot/exposed/interaction_map.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that represent Interaction instances accessed on a ExposedThing. +""" + +from collections import UserDict + +from reactivex.scheduler.eventloop import IOLoopScheduler +from slugify import slugify +from tornado import ioloop + + +class ExposedThingInteractionDict(UserDict): + """A dictionary that provides lazy access to the objects that implement + the Interaction interface for each interaction in a given ExposedThing.""" + + def __init__(self, *args, **kwargs): + self._exposed_thing = kwargs.pop("exposed_thing") + UserDict.__init__(self, *args, **kwargs) + + def _find_normalized_name(self, name): + """Takes a case-insensitive URL-safe interaction name and returns + the actual name in the interaction dict.""" + + return next((key for key in self.interaction_dict.keys() if slugify(key) == slugify(name)), None) + + def __getitem__(self, name): + """Lazily build and return an object that implements the Interaction interface.""" + + name_normalized = self._find_normalized_name(name) + + if name_normalized is None: + raise KeyError("Unknown interaction: {}".format(name)) + + return self.thing_interaction_class(self._exposed_thing, name_normalized) + + def __len__(self): + return len(self.interaction_dict) + + def __contains__(self, item): + return self._find_normalized_name(item) is not None + + def __iter__(self): + return iter(self.interaction_dict.keys()) + + @property + def interaction_dict(self): + """Returns the InteractionPattern objects dict by name.""" + + raise NotImplementedError() + + @property + def thing_interaction_class(self): + """Returns the class that implements the + Interaction interface for this type of interaction.""" + + raise NotImplementedError() + + +class ExposedThingPropertyDict(ExposedThingInteractionDict): + """A dictionary that provides lazy access to the objects that implement + the ThingProperty interface for each property in a given ExposedThing.""" + + @property + def interaction_dict(self): + return self._exposed_thing.thing.properties + + @property + def thing_interaction_class(self): + return ExposedThingProperty + + +class ExposedThingActionDict(ExposedThingInteractionDict): + """A dictionary that provides lazy access to the objects that implement + the ThingAction interface for each action in a given ExposedThing.""" + + @property + def interaction_dict(self): + return self._exposed_thing.thing.actions + + @property + def thing_interaction_class(self): + return ExposedThingAction + + +class ExposedThingEventDict(ExposedThingInteractionDict): + """A dictionary that provides lazy access to the objects that implement + the ThingEvent interface for each event in a given ExposedThing.""" + + @property + def interaction_dict(self): + return self._exposed_thing.thing.events + + @property + def thing_interaction_class(self): + return ExposedThingEvent + + +class ExposedThingProperty: + """The ThingProperty interface implementation for ExposedThing objects.""" + + def __init__(self, exposed_thing, name): + self._exposed_thing = exposed_thing + self._name = name + + def __str__(self): + return "<{}> ({}::{})".format(self.__class__.__name__, self._exposed_thing.id, self._name) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private init dict before propagating the exception.""" + + return getattr(self._exposed_thing.thing.properties[self._name], name) + + async def read(self): + """The get() method will fetch the value of the Property. + A coroutine that yields the value or raises an error.""" + + value = await self._exposed_thing.read_property(self._name) + return value + + async def write(self, value): + """The set() method will attempt to set the value of the + Property specified in the value argument whose type SHOULD + match the one specified by the type property. + A coroutine that yields on success or raises an error.""" + + await self._exposed_thing.write_property(self._name, value) + + def subscribe(self, *args, **kwargs): + """Subscribe to an stream of events emitted when the property value changes.""" + + observable = self._exposed_thing.on_property_change(self._name) + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + kwargs["scheduler"] = scheduler + + return observable.subscribe(*args, **kwargs) + + +class ExposedThingAction: + """The ThingAction interface implementation for ExposedThing objects.""" + + def __init__(self, exposed_thing, name): + self._exposed_thing = exposed_thing + self._name = name + + def __str__(self): + return "<{}> ({}::{})".format(self.__class__.__name__, self._exposed_thing.id, self._name) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private init dict before propagating the exception.""" + + return getattr(self._exposed_thing.thing.actions[self._name], name) + + async def invoke(self, *args): + """The run() method when invoked, starts the Action interaction + with the input value provided by the inputValue argument.""" + + input_value = args[0] if len(args) else None + result = await self._exposed_thing.invoke_action(self._name, input_value) + return result + + +class ExposedThingEvent: + """The ThingEvent interface implementation for ExposedThing objects.""" + + def __init__(self, exposed_thing, name): + self._exposed_thing = exposed_thing + self._name = name + + def __str__(self): + return "<{}> ({}::{})".format(self.__class__.__name__, self._exposed_thing.id, self._name) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private init dict before propagating the exception.""" + + return getattr(self._exposed_thing.thing.events[self._name], name) + + def subscribe(self, *args, **kwargs): + """Subscribe to an stream of emissions of this event.""" + + observable = self._exposed_thing.on_event(self._name) + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + kwargs["scheduler"] = scheduler + + return observable.subscribe(*args, **kwargs) + + def emit(self, payload): + """Emits an event that carries data specified by the payload argument.""" + + return self._exposed_thing.emit_event(self._name, payload) diff --git a/wotpy/wot/exposed/thing.py b/wotpy/wot/exposed/thing.py new file mode 100644 index 0000000..e27600f --- /dev/null +++ b/wotpy/wot/exposed/thing.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that represent Things exposed by a servient. +""" +import asyncio +import json + +import reactivex +from reactivex.scheduler.eventloop import IOLoopScheduler +from reactivex.subject import Subject +from reactivex import operators as ops +from tornado import ioloop + +from wotpy.utils.enums import EnumListMixin +from wotpy.utils.utils import to_camel +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.enums import DefaultThingEvent, TDChangeMethod, TDChangeType +from wotpy.wot.events import \ + EmittedEvent, \ + PropertyChangeEmittedEvent, \ + ThingDescriptionChangeEmittedEvent, \ + ActionInvocationEmittedEvent, \ + PropertyChangeEventInit, \ + ActionInvocationEventInit, \ + ThingDescriptionChangeEventInit +from wotpy.wot.exposed.interaction_map import \ + ExposedThingEventDict, \ + ExposedThingActionDict, \ + ExposedThingPropertyDict +from wotpy.wot.interaction import Property, Action, Event +from wotpy.wot.td import ThingDescription +from wotpy.wot.thing import Thing + + +class ExposedThing: + """An entity that serves to define the behavior of a Thing. + An application uses this class when it acts as the Thing 'server'.""" + + class HandlerKeys(EnumListMixin): + """Enumeration of handler keys.""" + + RETRIEVE_PROPERTY = "retrieve_property" + UPDATE_PROPERTY = "update_property" + INVOKE_ACTION = "invoke_action" + OBSERVE = "observe" + + class InteractionStateKeys(EnumListMixin): + """Enumeration of interaction state keys.""" + + PROPERTY_VALUES = "property_values" + + def __init__(self, servient, thing): + self._servient = servient + self._thing = thing + + self._interaction_states = { + self.InteractionStateKeys.PROPERTY_VALUES: {} + } + + self._handlers_global = { + self.HandlerKeys.RETRIEVE_PROPERTY: self._default_retrieve_property_handler, + self.HandlerKeys.UPDATE_PROPERTY: self._default_update_property_handler, + self.HandlerKeys.INVOKE_ACTION: self._default_invoke_action_handler + } + + self._handlers = { + self.HandlerKeys.RETRIEVE_PROPERTY: {}, + self.HandlerKeys.UPDATE_PROPERTY: {}, + self.HandlerKeys.INVOKE_ACTION: {} + } + + self._events_stream = Subject() + + def __str__(self): + return "<{}> {}".format(self.__class__.__name__, self.title) + + def __eq__(self, other): + return self.servient == other.servient and self.thing == other.thing + + def __hash__(self): + return hash((self.servient, self.thing)) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private Thing instance before propagating the exception.""" + + return getattr(self.thing, name) + + def __setattr__(self, name, value): + """Setter for ThingFragment attributes.""" + + name_camel = to_camel(name) + + if name_camel not in Thing.THING_FRAGMENT_WRITABLE_FIELDS: + return super(ExposedThing, self).__setattr__(name, value) + + return self._thing.__setattr__(name, value) + + def _set_property_value(self, prop, value): + """Sets a Property value.""" + + prop_values = self.InteractionStateKeys.PROPERTY_VALUES + self._interaction_states[prop_values][prop] = value + + def _get_property_value(self, prop): + """Returns a Property value.""" + + prop_values = self.InteractionStateKeys.PROPERTY_VALUES + return self._interaction_states[prop_values].get(prop, None) + + def _set_handler(self, handler_type, handler, interaction=None): + """Sets the currently defined handler for the given handler type.""" + + if interaction is None or handler_type not in self._handlers: + self._handlers_global[handler_type] = handler + else: + self._handlers[handler_type][interaction] = handler + + def _get_handler(self, handler_type, interaction=None): + """Returns the currently defined handler for the given handler type.""" + + interaction_handler = self._handlers.get(handler_type, {}).get(interaction, None) + return interaction_handler or self._handlers_global[handler_type] + + def _find_interaction(self, name): + """Raises ValueError if the given interaction does not exist in this Thing.""" + + interaction = self._thing.find_interaction(name=name) + + if not interaction: + raise ValueError("Interaction not found: {}".format(name)) + + return interaction + + def _default_retrieve_property_handler(self, property_name): + """Default handler for property reads.""" + + loop = asyncio.get_running_loop() + future_read = loop.create_future() + prop = self._find_interaction(name=property_name) + prop_value = self._get_property_value(prop) + future_read.set_result(prop_value) + + return future_read + + def _default_update_property_handler(self, property_name, value): + """Default handler for onUpdateProperty.""" + + loop = asyncio.get_running_loop() + future_write = loop.create_future() + prop = self._find_interaction(name=property_name) + self._set_property_value(prop, value) + future_write.set_result(None) + + return future_write + + # noinspection PyMethodMayBeStatic,PyUnusedLocal + def _default_invoke_action_handler(self, parameters): + """Default handler for onInvokeAction.""" + + loop = asyncio.get_running_loop() + future_invoke = loop.create_future() + future_invoke.set_exception(NotImplementedError("Undefined action handler")) + + return future_invoke + + @property + def id(self): + """Returns the ID of the Thing.""" + + return self.thing.id + + @property + def title(self): + """Returns the title of the Thing.""" + + return self.thing.title + + @property + def servient(self): + """Servient that contains this ExposedThing.""" + + return self._servient + + @property + def thing(self): + """Returns the object that represents the Thing beneath this ExposedThing.""" + + return self._thing + + @property + def properties(self): + """Returns a dictionary of ThingProperty items.""" + + return ExposedThingPropertyDict(exposed_thing=self) + + @property + def actions(self): + """Returns a dictionary of ThingAction items.""" + + return ExposedThingActionDict(exposed_thing=self) + + @property + def events(self): + """Returns a dictionary of ThingEvent items.""" + + return ExposedThingEventDict(exposed_thing=self) + + async def read_property(self, name): + """Takes the Property name as the name argument, then requests from + the underlying platform and the Protocol Bindings to retrieve the + Property on the remote Thing and return the result. Returns a Future + that resolves with the Property value or rejects with an Error.""" + + proprty = self.thing.properties[name] + + handler = self._handlers.get(self.HandlerKeys.RETRIEVE_PROPERTY, {}).get(proprty, None) + + if handler: + value = await handler() + else: + value = await self._default_retrieve_property_handler(name) + + return value + + async def handle_write_property(self, name, value): + """Function that gets called from protocol servers to handle external write + requests. In contrast to internal writes that are allowed on readOnly properties, + external property writes fail.""" + + proprty = self.properties[name] + + if not proprty.writable: + raise TypeError("Property is non-writable") + + await proprty.write(value) + + def _extract_simple_kv(self, dictionary): + """Extracts simple key-value pairs from a nested dictionary.""" + + simple_kv = {} + for key, value in dictionary.items(): + if isinstance(value, dict): + simple_kv.update(self._extract_simple_kv(value)) + else: + simple_kv[key] = value + return simple_kv + + def _write_to_db(self, name, value): + """Writes the value in the specified bucket creating the bucket if it + doesn't exist.""" + + if isinstance(value, list): + value = json.dumps(value) + + async def write_property(self, name, value): + """Takes the Property name as the name argument and the new value as the + value argument, then requests from the underlying platform and the Protocol + Bindings to update the Property on the remote Thing and return the result. + Returns a Future that resolves on success or rejects with an Error.""" + + proprty = self.thing.properties[name] + + handler = self._handlers.get(self.HandlerKeys.UPDATE_PROPERTY, {}).get(proprty, None) + + if handler: + await handler(value) + else: + await self._default_update_property_handler(name, value) + + event_init = PropertyChangeEventInit(name=name, value=value) + emitted_event = PropertyChangeEmittedEvent(init=event_init) + + self._events_stream.on_next(emitted_event) + + async def invoke_action(self, name, input_value=None): + """Invokes an Action with the given parameters and yields with the invocation result.""" + + action = self.thing.actions[name] + + handler = self._get_handler( + handler_type=self.HandlerKeys.INVOKE_ACTION, + interaction=action) + + result = await handler({ + "input": input_value + }) + + event_init = ActionInvocationEventInit(action_name=name, return_value=result) + emitted_event = ActionInvocationEmittedEvent(init=event_init) + + self._events_stream.on_next(emitted_event) + + return result + + def on_event(self, name): + """Returns an Observable for the Event specified in the name argument, + allowing subscribing to and unsubscribing from notifications.""" + + if name not in self.thing.events: + # noinspection PyUnresolvedReferences + return reactivex.throw(Exception("Unknown event")) + + def event_filter(item): + return item.name == name + + # noinspection PyUnresolvedReferences + return self._events_stream.pipe(ops.filter(event_filter)) + + def on_property_change(self, name): + """Returns an Observable for the Property specified in the name argument, + allowing subscribing to and unsubscribing from notifications.""" + + try: + interaction = self._find_interaction(name=name) + except ValueError: + # noinspection PyUnresolvedReferences + return reactivex.throw(Exception("Unknown property")) + + if not interaction.observable: + # noinspection PyUnresolvedReferences + return reactivex.throw(Exception("Property is not observable")) + + def property_change_filter(item): + return item.name == DefaultThingEvent.PROPERTY_CHANGE and \ + item.data.name == name + + # noinspection PyUnresolvedReferences + return self._events_stream.pipe(ops.filter(property_change_filter)) + + def on_td_change(self): + """Returns an Observable, allowing subscribing to and unsubscribing + from notifications to the Thing Description.""" + + def td_change_filter(item): + return item.name == DefaultThingEvent.DESCRIPTION_CHANGE + + # noinspection PyUnresolvedReferences + return self._events_stream.pipe(ops.filter(td_change_filter)) + + def expose(self): + """Start serving external requests for the Thing, so that + WoT interactions using Properties, Actions and Events will be possible.""" + + self._servient.enable_exposed_thing(self.thing.title) + + def destroy(self): + """Stop serving external requests for the Thing and destroy the object. + Note that eventual unregistering should be done before invoking this method.""" + + self._servient.remove_exposed_thing(self.thing.title) + + def emit_event(self, event_name, payload): + """Emits an the event initialized with the event name specified by + the event_name argument and data specified by the payload argument.""" + + if not self.thing.find_interaction(name=event_name): + raise ValueError("Unknown event: {}".format(event_name)) + + event = EmittedEvent(name=event_name, init=payload) + + self._events_stream.on_next(event) + + def add_property(self, name, property_init, value=None): + """Adds a Property defined by the argument and updates the Thing Description. + Takes an instance of ThingPropertyInit as argument.""" + + if isinstance(property_init, dict): + property_init = PropertyFragmentDict(property_init) + + prop = Property(thing=self._thing, name=name, init_dict=property_init) + + self._thing.add_interaction(prop) + self._set_property_value(prop, value) + + event_data = ThingDescriptionChangeEventInit( + td_change_type=TDChangeType.PROPERTY, + method=TDChangeMethod.ADD, + name=name, + data=property_init.to_dict(), + description=ThingDescription.from_thing(self.thing).to_dict()) + + event = ThingDescriptionChangeEmittedEvent(init=event_data) + + self._events_stream.on_next(event) + + def remove_property(self, name): + """Removes the Property specified by the name argument, + updates the Thing Description and returns the object.""" + + self._thing.remove_interaction(name=name) + + event_data = ThingDescriptionChangeEventInit( + td_change_type=TDChangeType.PROPERTY, + method=TDChangeMethod.REMOVE, + name=name) + + event = ThingDescriptionChangeEmittedEvent(init=event_data) + + self._events_stream.on_next(event) + + def add_action(self, name, action_init, action_handler=None): + """Adds an Action to the Thing object as defined by the action + argument of type ThingActionInit and updates the Thing Description.""" + + if isinstance(action_init, dict): + action_init = ActionFragmentDict(action_init) + + action = Action(thing=self._thing, name=name, init_dict=action_init) + + self._thing.add_interaction(action) + + event_data = ThingDescriptionChangeEventInit( + td_change_type=TDChangeType.ACTION, + method=TDChangeMethod.ADD, + name=name, + data=action_init.to_dict(), + description=ThingDescription.from_thing(self.thing).to_dict()) + + event = ThingDescriptionChangeEmittedEvent(init=event_data) + + self._events_stream.on_next(event) + + if action_handler: + self.set_action_handler(name, action_handler) + + def remove_action(self, name): + """Removes the Action specified by the name argument, + updates the Thing Description and returns the object.""" + + self._thing.remove_interaction(name=name) + + event_data = ThingDescriptionChangeEventInit( + td_change_type=TDChangeType.ACTION, + method=TDChangeMethod.REMOVE, + name=name) + + event = ThingDescriptionChangeEmittedEvent(init=event_data) + + self._events_stream.on_next(event) + + def add_event(self, name, event_init): + """Adds an event to the Thing object as defined by the event argument + of type ThingEventInit and updates the Thing Description.""" + + if isinstance(event_init, dict): + event_init = EventFragmentDict(event_init) + + event = Event(thing=self._thing, name=name, init_dict=event_init) + + self._thing.add_interaction(event) + + event_data = ThingDescriptionChangeEventInit( + td_change_type=TDChangeType.EVENT, + method=TDChangeMethod.ADD, + name=name, + data=event_init.to_dict(), + description=ThingDescription.from_thing(self.thing).to_dict()) + + event = ThingDescriptionChangeEmittedEvent(init=event_data) + + self._events_stream.on_next(event) + + def remove_event(self, name): + """Removes the event specified by the name argument, + updates the Thing Description and returns the object.""" + + self._thing.remove_interaction(name=name) + + event_data = ThingDescriptionChangeEventInit( + td_change_type=TDChangeType.EVENT, + method=TDChangeMethod.REMOVE, + name=name) + + event = ThingDescriptionChangeEmittedEvent(init=event_data) + + self._events_stream.on_next(event) + + def set_action_handler(self, name, action_handler): + """Takes name as string argument and action_handler as argument of type ActionHandler. + Sets the handler function for the specified Action matched by name. + Throws on error. Returns a reference to the same object for supporting chaining.""" + + action = self.thing.actions[name] + + self._set_handler( + handler_type=self.HandlerKeys.INVOKE_ACTION, + handler=action_handler, + interaction=action) + + return self + + def set_property_read_handler(self, name, read_handler): + """Takes name as string argument and read_handler as argument of type PropertyReadHandler. + Sets the handler function for reading the specified Property matched by name. + Throws on error. Returns a reference to the same object for supporting chaining.""" + + proprty = self.thing.properties[name] + + self._set_handler( + handler_type=self.HandlerKeys.RETRIEVE_PROPERTY, + handler=read_handler, + interaction=proprty) + + return self + + def set_property_write_handler(self, name, write_handler): + """Takes name as string argument and write_handler as argument of type PropertyWriteHandler. + Sets the handler function for writing the specified Property matched by name. + Throws on error. Returns a reference to the same object for supporting chaining.""" + + proprty = self.thing.properties[name] + + self._set_handler( + handler_type=self.HandlerKeys.UPDATE_PROPERTY, + handler=write_handler, + interaction=proprty) + + return self + + def subscribe(self, *args, **kwargs): + """Subscribes to changes on the TD of this thing.""" + + observable = self.on_td_change() + loop = ioloop.IOLoop.current() + scheduler = IOLoopScheduler(loop) + kwargs["scheduler"] = scheduler + + return observable.subscribe(*args, **kwargs) diff --git a/wotpy/wot/exposed/thing_set.py b/wotpy/wot/exposed/thing_set.py new file mode 100644 index 0000000..16a4be6 --- /dev/null +++ b/wotpy/wot/exposed/thing_set.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents a group or set of ExposedThing instances that exist in the same context. +""" + + +class ExposedThingSet: + """Represents a group of ExposedThing objects. + A group cannot contain two ExposedThing with the same Thing ID.""" + + def __init__(self): + self._exposed_things = {} + + @property + def exposed_things(self): + """A generator that yields all the ExposedThing contained in this group.""" + + for exposed_thing in self._exposed_things.values(): + yield exposed_thing + + def contains(self, exposed_thing): + """Returns True if this group contains the given ExposedThing.""" + + return exposed_thing in self._exposed_things.values() + + def add(self, exposed_thing): + """Add a new ExposedThing to this set.""" + + if exposed_thing.thing.title in self._exposed_things: + raise ValueError("Duplicate Exposed Thing: {}".format(exposed_thing.title)) + + self._exposed_things[exposed_thing.thing.title] = exposed_thing + + def remove(self, thing_name): + """Removes an existing ExposedThing by Name.""" + + exposed_thing = self.find_by_thing_name(thing_name) + + if exposed_thing is None: + raise ValueError("Unknown Exposed Thing: {}".format(thing_name)) + + assert exposed_thing.thing.title in self._exposed_things + self._exposed_things.pop(exposed_thing.thing.title) + + def find_by_thing_name(self, thing_name): + """Finds an existing ExposedThing by Thing Name.""" + + def is_match(exp_thing): + return exp_thing.thing.title == thing_name or exp_thing.thing.url_name == thing_name + + return next((item for item in self._exposed_things.values() if is_match(item)), None) + + def find_by_interaction(self, interaction): + """Finds the ExposedThing whose Thing contains the given Interaction.""" + + def is_match(exp_thing): + return exp_thing.thing is interaction.thing + + return next((item for item in self._exposed_things.values() if is_match(item)), None) diff --git a/wotpy/wot/form.py b/wotpy/wot/form.py new file mode 100644 index 0000000..a00aaaa --- /dev/null +++ b/wotpy/wot/form.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents the form entities exposed by interactions. +""" + +from wotpy.wot.dictionaries.link import FormDict + + +class Form: + """Communication metadata where a service can be accessed by a client application.""" + + def __init__(self, interaction, protocol, form_dict=None, **kwargs): + self._interaction = interaction + self._protocol = protocol + self._form_dict = form_dict if form_dict else FormDict(**kwargs) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the internal Form init dict before propagating the exception.""" + + return getattr(self._form_dict, name) + + @property + def form_dict(self): + """The Form dictionary of this Form.""" + + return self._form_dict + + @property + def interaction(self): + """Interaction that contains this Form.""" + + return self._interaction + + @property + def protocol(self): + """Form protocol.""" + + return self._protocol + + @property + def id(self): + """Returns the ID of this Form. + The ID is a hash that is based on the Form attributes. + No two Forms with the same ID may exist within the same Interaction. + The ID of a Form could change during its lifetime if some attributes are updated.""" + + return hash(( + self.protocol, + self.href, + self.content_type, + tuple(self.op) if isinstance(self.op, list) else self.op + )) diff --git a/wotpy/wot/interaction.py b/wotpy/wot/interaction.py new file mode 100644 index 0000000..1eeb882 --- /dev/null +++ b/wotpy/wot/interaction.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that represent all interaction patterns. +""" + +from abc import ABCMeta, abstractmethod + +# noinspection PyPackageRequirements +from slugify import slugify + +from wotpy.codecs.enums import MediaTypes +from wotpy.wot.enums import InteractionTypes +from wotpy.wot.validation import is_valid_safe_name +from wotpy.wot.dictionaries.interaction import PropertyFragmentDict, ActionFragmentDict, EventFragmentDict +from wotpy.wot.form import Form + + +class InteractionPattern(metaclass=ABCMeta): + """A functionality exposed by Thing that is defined by the TD Interaction Model.""" + + def __init__(self, thing, name, init_dict=None, **kwargs): + if not is_valid_safe_name(name): + raise ValueError("Invalid Interaction name: {}".format(name)) + + self._init_dict = init_dict if init_dict else self.init_class(**kwargs) + + self._thing = thing + self._name = name + self._autogenerated_forms = [] + self._td_forms = [] + if self._init_dict.forms: + self._td_forms = [ + Form( + interaction=self, + protocol=None, + href=form_input.href, + content_type=MediaTypes.JSON, + op=form_input.op) + for form_input in self._init_dict.forms + ] + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private init dict before propagating the exception.""" + + return getattr(self._init_dict, name) + + @property + @abstractmethod + def init_class(self): + """Returns the init dict class for this type of interaction.""" + + raise NotImplementedError() + + @property + def interaction_fragment(self): + """The InteractionFragment dictionary of this interaction.""" + + return self._init_dict + + @property + def thing(self): + """Thing that contains this Interaction.""" + + return self._thing + + @property + def name(self): + """Interaction name. + No two Interactions with the same name may exist in a Thing.""" + + return self._name + + @property + def url_name(self): + """URL-safe version of the name.""" + + return slugify(self.name) + + @property + def forms(self): + """Sequence of forms linked to this interaction.""" + + return self._td_forms + self._autogenerated_forms + + def clean_forms(self): + """Removes all autogenerated Forms from this Interaction.""" + + self._autogenerated_forms = [] + + def add_form(self, form): + """Add a new autogenerated Form.""" + + assert form.interaction is self + + existing = next((True for item in self._autogenerated_forms if item.id == form.id), False) + + if existing: + raise ValueError("Duplicate Form: {}".format(form)) + + self._autogenerated_forms.append(form) + + def remove_form(self, form): + """Remove an existing autogenerated Form.""" + + try: + pop_idx = self._autogenerated_forms.index(form) + self._autogenerated_forms.pop(pop_idx) + except ValueError: + pass + + +class Property(InteractionPattern): + """Properties expose internal state of a Thing that can be + directly accessed (get) and optionally manipulated (set).""" + + @property + def init_class(self): + """Returns the init dict class for this type of interaction.""" + + return PropertyFragmentDict + + @property + def interaction_type(self): + """Interaction type.""" + + return InteractionTypes.PROPERTY + + +class Action(InteractionPattern): + """Actions offer functions of the Thing. These functions may manipulate the + internal state of a Thing in a way that is not possible through setting Properties.""" + + @property + def init_class(self): + """Returns the init dict class for this type of interaction.""" + + return ActionFragmentDict + + @property + def interaction_type(self): + """Interaction type.""" + + return InteractionTypes.ACTION + + +class Event(InteractionPattern): + """The Event Interaction Pattern describes event sources that asynchronously push messages. + Here not state, but state transitions (events) are communicated (e.g., clicked).""" + + @property + def init_class(self): + """Returns the init dict class for this type of interaction.""" + + return EventFragmentDict + + @property + def interaction_type(self): + """Interaction type.""" + + return InteractionTypes.EVENT diff --git a/wotpy/wot/servient.py b/wotpy/wot/servient.py new file mode 100644 index 0000000..1a9de36 --- /dev/null +++ b/wotpy/wot/servient.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents a WoT servient. +""" + +import asyncio +import functools +import re +import socket +import time + +import tornado.web +from wotpy.protocols.enums import Protocols +from wotpy.protocols.http.client import HTTPClient +from wotpy.protocols.ws.client import WebsocketClient +from wotpy.support import (is_coap_supported, is_dnssd_supported, + is_mqtt_supported) +from wotpy.utils.utils import get_main_ipv4_address +from wotpy.wot.enums import InteractionTypes +from wotpy.wot.exposed.thing_set import ExposedThingSet +from wotpy.wot.td import ThingDescription +from wotpy.wot.wot import WoT + + +class TDHandler(tornado.web.RequestHandler): + """Handler that returns the TD document of a given Thing.""" + + def initialize(self, servient): + self.servient = servient + + def get(self, thing_url_name): + exp_thing = self.servient.exposed_thing_set.find_by_thing_name( + thing_url_name) + + td_doc = ThingDescription.from_thing(exp_thing.thing).to_dict() + base_url = self.servient.get_thing_base_url(exp_thing) + + if base_url: + td_doc.update({"base": base_url}) + + self.write(td_doc) + + +class TDCatalogueHandler(tornado.web.RequestHandler): + """Handler that returns the entire catalogue of Things contained in this servient. + May return TDs in expanded format or URL pointers to the individual TDs.""" + + def initialize(self, servient): + self.servient = servient + + def get(self): + response = {} + + for exp_thing in self.servient.enabled_exposed_things: + thing_name = exp_thing.thing.title + + if self.get_argument("expanded", False): + val = ThingDescription.from_thing(exp_thing.thing).to_dict() + val.update( + {"base": self.servient.get_thing_base_url(exp_thing)}) + else: + val = "/{}".format(exp_thing.thing.url_name) + + response[thing_name] = val + + self.write(response) + + +class ServientStateException(Exception): + """Exception raised when the user modifies the Servient while + the Servient is in an inappropriate state.""" + + pass + + +def _stopped_servient_only(func): + """Decorator that raises an Exception when attempting + to call the function while the Servient is running.""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + servient = args[0] + + if servient.is_running: + raise ServientStateException( + "Attempted to modify the Servient while it was running") + + return func(*args, **kwargs) + + return wrapper + + +_REGEX_ARPA = r".*\.(ip6|in-addr)\.arpa$" + + +def _get_hostname_fallback(): + """Tries to guess the hostname of the current host that should be used on TD Forms. + Two strategies are used for this: First, the socket.getfqdn() method. If the returned + value is not a FQDN then we try to get the IPv4 address of the main network interface.""" + + fqdn = socket.getfqdn() + valid_fqdn = fqdn and not re.search(_REGEX_ARPA, fqdn) + return fqdn if valid_fqdn else get_main_ipv4_address() + + +class Servient: + """An entity that is both a WoT client and server at the same time. + WoT servers are Web servers that possess capabilities to access underlying + IoT devices and expose a public interface named the WoT Interface that may + be used by other clients. + WoT clients are entities that are able to understand the WoT Interface to + send requests and interact with IoT devices exposed by other WoT servients + or servers using the capabilities of a Web client such as Web browser.""" + + def __init__(self, hostname=None, catalogue_port=9090, + clients=None, clients_config=None, create_default_forms=True, + dnssd_enabled=False, dnssd_instance_name=None): + self._hostname = hostname if hostname is not None else _get_hostname_fallback() + + if not isinstance(self._hostname, str): + raise ValueError("Invalid hostname: {}".format(self._hostname)) + + if isinstance(clients, list): + clients = {item.protocol: item for item in clients} + + self._servers = {} + self._clients = clients if clients else {} + self._clients_config = clients_config + self._catalogue_port = catalogue_port + self._catalogue_server = None + self._exposed_thing_set = ExposedThingSet() + self._servient_lock = asyncio.Lock() + self._is_running = False + self._dnssd_enabled = dnssd_enabled if dnssd_enabled and is_dnssd_supported() else False + self._dnssd_instance_name = dnssd_instance_name + self._dnssd = None + self._create_default_forms = create_default_forms + self._enabled_exposed_thing_names = set() + self._credential_store = {} + + if not len(self._clients): + self._build_default_clients() + + @staticmethod + def _default_select_client(clients, td, name): + """Default implementation of the function to select + a Protocol Binding client for an Interaction.""" + + protocol_preference_map = { + InteractionTypes.PROPERTY: [ + Protocols.MQTT, + Protocols.HTTP, + Protocols.COAP, + Protocols.WEBSOCKETS, + ], + InteractionTypes.ACTION: [ + Protocols.HTTP, + Protocols.WEBSOCKETS, + Protocols.MQTT, + Protocols.COAP + ], + InteractionTypes.EVENT: [ + Protocols.WEBSOCKETS, + Protocols.MQTT, + Protocols.COAP, + Protocols.HTTP + ] + } + + supported_protocols = [ + client.protocol for client in clients + if client.is_supported_interaction(td, name) + ] + + intrct_names = { + InteractionTypes.PROPERTY: td.properties.keys(), + InteractionTypes.ACTION: td.actions.keys(), + InteractionTypes.EVENT: td.events.keys() + } + + try: + intrct_type = next(key for key, names in + intrct_names.items() if name in names) + except StopIteration: + raise ValueError("Unknown interaction: {}".format(name)) + + protocol_prefs = protocol_preference_map[intrct_type] + protocol_choices = set(protocol_prefs).intersection( + set(supported_protocols)) + + if not len(protocol_choices): + return list(clients)[0] + + protocol = next( + proto for proto in protocol_prefs if proto in protocol_choices) + + return next(client for client in clients if client.protocol == protocol) + + @property + def is_running(self): + """Returns True if the Servient is currently running + (i.e. the attached servers have been started).""" + + return self._is_running + + @property + def hostname(self): + """Hostname attached to this servient.""" + + return self._hostname + + @property + def exposed_thing_set(self): + """Returns the ExposedThingSet instance that + contains the ExposedThings of this servient.""" + + return self._exposed_thing_set + + @property + def exposed_things(self): + """Returns an iterator for the ExposedThings contained in this Servient.""" + + return self.exposed_thing_set.exposed_things + + @property + def enabled_exposed_things(self): + """Returns an iterator for the enabled ExposedThings contained in this Servient.""" + + for exposed_thing in self.exposed_things: + if exposed_thing.title in self._enabled_exposed_thing_names: + yield exposed_thing + + @property + def servers(self): + """Returns the dict of Protocol Binding servers attached to this servient.""" + + return self._servers + + @property + def clients(self): + """Returns the dict of Protocol Binding clients attached to this servient.""" + + return self._clients + + @property + def catalogue_port(self): + """Returns the current port of the HTTP Thing Description catalogue service.""" + + return self._catalogue_port + + @catalogue_port.setter + @_stopped_servient_only + def catalogue_port(self, port): + """Enables the servient TD catalogue in the given port.""" + + self._catalogue_port = port + + @property + def dnssd(self): + """Returns the DNS-SD instance linked to this Servient (if enabled and started).""" + + return self._dnssd + + @property + def dnssd_instance_name(self): + """Returns the user-given DNS-SD service instance name.""" + + return self._dnssd_instance_name + + async def _start_dnssd(self): + """Starts the DNS-SD service and registers the servient.""" + + if self._dnssd or not self._dnssd_enabled: + return + + from wotpy.wot.discovery.dnssd.service import DNSSDDiscoveryService + + self._dnssd = DNSSDDiscoveryService() + + await self._dnssd.start() + await self._dnssd.register(self, instance_name=self._dnssd_instance_name) + + async def _stop_dnssd(self): + """Unregisters the servient and stops the DNS-SD service.""" + + if not self._dnssd: + return + + await self._dnssd.stop() + + self._dnssd = None + + def _build_default_clients(self): + """Builds the default Protocol Binding clients.""" + + self._clients = self._clients if self._clients else {} + + conf = self._clients_config if self._clients_config else {} + + self._clients.update({ + Protocols.WEBSOCKETS: WebsocketClient(**conf.get(Protocols.WEBSOCKETS, {})), + Protocols.HTTP: HTTPClient(**conf.get(Protocols.HTTP, {})) + }) + + if is_coap_supported(): + from wotpy.protocols.coap.client import CoAPClient + self._clients.update( + {Protocols.COAP: CoAPClient(**conf.get(Protocols.COAP, {}))}) + + if is_mqtt_supported(): + from wotpy.protocols.mqtt.client import MQTTClient + self._clients.update( + {Protocols.MQTT: MQTTClient(**conf.get(Protocols.MQTT, {}))}) + + def _build_td_catalogue_app(self): + """Returns a Tornado app that provides one endpoint to retrieve the + entire catalogue of thing descriptions contained in this servient.""" + + return tornado.web.Application([ + (r"/", TDCatalogueHandler, dict(servient=self)), + (r"/(?P[^\/]+)", TDHandler, dict(servient=self)) + ]) + + def _start_catalogue(self): + """Starts the TD catalogue server if enabled.""" + + if self._catalogue_server or not self._catalogue_port: + return + + catalogue_app = self._build_td_catalogue_app() + self._catalogue_server = catalogue_app.listen(self._catalogue_port) + + def _stop_catalogue(self): + """Stops the TD catalogue server if running.""" + + if not self._catalogue_server: + return + + self._catalogue_server.stop() + self._catalogue_server = None + + def _clean_forms(self): + """Cleans all the autogenerated Forms from all the ExposedThings + contained in this Servient.""" + + for exposed_thing in self._exposed_thing_set.exposed_things: + for interaction in exposed_thing.thing.interactions: + interaction.clean_forms() + + def _clean_protocol_forms(self, exposed_thing, protocol): + """Removes all autogenerated interaction forms linked to this + server protocol for the given ExposedThing.""" + + assert self._exposed_thing_set.contains(exposed_thing) + assert protocol in self._servers + + for interaction in exposed_thing.thing.interactions: + forms_to_remove = [ + form for form in interaction.forms + if form.protocol == protocol + ] + + for form in forms_to_remove: + interaction.remove_form(form) + + def _server_has_exposed_thing(self, server, exposed_thing): + """Returns True if the given server contains the ExposedThing.""" + + assert server in self._servers.values() + assert self._exposed_thing_set.contains(exposed_thing) + + return server.exposed_thing_set.contains(exposed_thing) + + def _add_interaction_forms(self, server, exposed_thing): + """Builds and adds to the ExposedThing the Links related to the given server.""" + + assert server in self._servers.values() + assert self._exposed_thing_set.contains(exposed_thing) + + for interaction in exposed_thing.thing.interactions: + forms = server.build_forms( + hostname=self._hostname, interaction=interaction) + + for form in forms: + interaction.add_form(form) + + def _regenerate_server_forms(self, server): + """Cleans and regenerates Forms for the given server in all ExposedThings.""" + + assert server in self._servers.values() + + for exp_thing in self._exposed_thing_set.exposed_things: + self._clean_protocol_forms(exp_thing, server.protocol) + if self._server_has_exposed_thing(server, exp_thing): + self._add_interaction_forms(server, exp_thing) + + def get_thing_base_url(self, exposed_thing): + """Return the base URL for the given ExposedThing + for one of the currently active servers.""" + + if exposed_thing.thing.base: + return exposed_thing.base + + if not self.exposed_thing_set.contains(exposed_thing): + raise ValueError("Unknown ExposedThing") + + if not len(self.servers): + return None + + protocol_default = sorted(self.servers.keys())[0] + protocol = Protocols.HTTP if Protocols.HTTP in self.servers else protocol_default + server = self.servers[protocol] + + return server.build_base_url(hostname=self.hostname, thing=exposed_thing.thing) + + def add_credentials(self, credentials_dict): + """Adds a dictionary of credentials for a specific thing.""" + + for thing_name, creds in credentials_dict.items(): + if thing_name in self._credential_store: + self._credential_store[thing_name].update(creds) + else: + self._credential_store[thing_name] = creds + + def retrieve_credentials(self, exposed_thing_title): + """Retrieves all the credentials associated with the given thing.""" + + return self._credential_store.get(exposed_thing_title, None) + + def select_client(self, td, name): + """Returns the Protocol Binding client instance to + communicate with the given Interaction.""" + + return Servient._default_select_client(self.clients.values(), td, name) + + @_stopped_servient_only + def add_client(self, client): + """Adds a new Protocol Binding client to this servient.""" + + self._clients[client.protocol] = client + + @_stopped_servient_only + def remove_client(self, protocol): + """Removes the Protocol Binding client with the given protocol from this servient.""" + + self._clients.pop(protocol, None) + + @_stopped_servient_only + def add_server(self, server): + """Adds a new Protocol Binding server to this servient.""" + + self._servers[server.protocol] = server + + @_stopped_servient_only + def remove_server(self, protocol): + """Removes the Protocol Binding server with the given protocol from this servient.""" + + self._servers.pop(protocol, None) + + def refresh_forms(self): + """Cleans and regenerates autogenerated Forms for all the + ExposedThings and servers contained in this servient.""" + + self._clean_forms() + + for server in self._servers.values(): + self._regenerate_server_forms(server) + + def enable_exposed_thing(self, thing_name): + """Enables the ExposedThing with the given Name. + This is, the servers will listen for requests for this thing.""" + + exposed_thing = self.get_exposed_thing(thing_name) + + for server in self._servers.values(): + server.add_exposed_thing(exposed_thing) + self._regenerate_server_forms(server) + + self._enabled_exposed_thing_names.add(exposed_thing.title) + + def disable_exposed_thing(self, thing_name): + """Disables the ExposedThing with the given Name. + This is, the servers will not listen for requests for this thing.""" + + exposed_thing = self.get_exposed_thing(thing_name) + + if exposed_thing.title not in self._enabled_exposed_thing_names: + raise ValueError( + "ExposedThing {} is already disabled".format(thing_name)) + + for server in self._servers.values(): + server.remove_exposed_thing(exposed_thing.title) + self._regenerate_server_forms(server) + + self._enabled_exposed_thing_names.remove(exposed_thing.title) + + def add_exposed_thing(self, exposed_thing): + """Adds an ExposedThing to this Servient. + ExposedThings are disabled by default.""" + + self._exposed_thing_set.add(exposed_thing) + + def remove_exposed_thing(self, thing_name): + """Disables and removes an ExposedThing from this Servient.""" + + if thing_name in self._enabled_exposed_thing_names: + self.disable_exposed_thing(thing_name) + + self._exposed_thing_set.remove(thing_name) + + def get_exposed_thing(self, thing_name): + """Finds and returns an ExposedThing contained in this servient by Thing Name. + Raises ValueError if the ExposedThing is not present.""" + + exp_thing = self._exposed_thing_set.find_by_thing_name(thing_name) + + if exp_thing is None: + raise ValueError("Unknown ExposedThing: {}".format(thing_name)) + + return exp_thing + + @_stopped_servient_only + def disable_td_catalogue(self): + """Disables the servient TD catalogue.""" + + self._catalogue_port = None + + async def start(self): + """Starts the servers and returns an instance of the WoT object.""" + + async with self._servient_lock: + if self._create_default_forms: + self.refresh_forms() + for server in self._servers.values(): + await server.start(self) + self._start_catalogue() + await self._start_dnssd() + self._is_running = True + + return WoT(servient=self) + + async def shutdown(self): + """Stops the server configured under this servient.""" + + async with self._servient_lock: + for server in self._servers.values(): + await server.stop() + self._stop_catalogue() + await self._stop_dnssd() + self._is_running = False diff --git a/wotpy/wot/td.py b/wotpy/wot/td.py new file mode 100644 index 0000000..6d5fbd2 --- /dev/null +++ b/wotpy/wot/td.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Classes that represent the JSON and JSON-LD serialization formats of a Thing Description document. +""" + +import json + +import jsonschema + +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.thing import Thing +from wotpy.wot.validation import SCHEMA_THING, InvalidDescription + + +class ThingDescription: + """Class that represents a Thing Description document. + Contains logic to validate and transform a Thing to a serialized TD and vice versa.""" + + def __init__(self, doc): + """Constructor. + Validates that the document conforms to the TD schema.""" + + self._doc = json.loads(doc) if isinstance(doc, (str, bytes)) else doc + self._thing_fragment = ThingFragment(self._doc) + + self.validate(doc=self._thing_fragment.to_dict()) + + @classmethod + def validate(cls, doc): + """Validates the given Thing Description document against its schema. + Raises ValidationError if validation fails.""" + + try: + jsonschema.validate(doc, SCHEMA_THING) + except (jsonschema.ValidationError, TypeError) as ex: + raise InvalidDescription(str(ex)) + + @classmethod + def from_thing(cls, thing): + """Builds an instance of a JSON-serialized Thing Description from a Thing object.""" + + return ThingDescription(thing.thing_fragment.to_dict()) + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the internal ThingFragment before propagating the exception.""" + + return getattr(self._thing_fragment, name) + + def to_dict(self): + """Returns the JSON Thing Description as a dict.""" + + return self._thing_fragment.to_dict() + + def to_str(self): + """Returns the JSON Thing Description as a string.""" + + return json.dumps(self._thing_fragment.to_dict()) + + def to_thing_fragment(self): + """Returns a ThingFragment dictionary built from this TD.""" + + return self._thing_fragment + + def build_thing(self): + """Builds a new Thing object from the serialized Thing Description.""" + + return Thing(thing_fragment=self.to_thing_fragment()) + + def get_forms(self, name): + """Returns a list of FormDict for the interaction that matches the given name.""" + + if name in self.properties: + return self.get_property_forms(name) + + if name in self.actions: + return self.get_action_forms(name) + + if name in self.events: + return self.get_event_forms(name) + + return [] + + def get_property_forms(self, name): + """Returns a list of FormDict for the property that matches the given name.""" + + return self.properties[name].forms + + def get_action_forms(self, name): + """Returns a list of FormDict for the action that matches the given name.""" + + return self.actions[name].forms + + def get_event_forms(self, name): + """Returns a list of FormDict for the event that matches the given name.""" + + return self.events[name].forms diff --git a/wotpy/wot/thing.py b/wotpy/wot/thing.py new file mode 100644 index 0000000..5fe8d56 --- /dev/null +++ b/wotpy/wot/thing.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that represents a Thing. +""" + +import hashlib +import itertools +import uuid + +from slugify import slugify + +from wotpy.utils.utils import to_camel +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.interaction import Property, Action, Event + + +class Thing: + """An abstraction of a physical or virtual entity whose metadata + and interfaces are described by a WoT Thing Description.""" + + THING_FRAGMENT_WRITABLE_FIELDS = { + "@context", + "@type", + "id", + "title", + "titles", + "description", + "descriptions", + "version", + "created", + "modified", + "support", + "base", + "properties", + "actions", + "events", + "links", + "forms", + "security", + "securityDefinitions", + "profile", + "schemaDefinitions", + "uriVariables" + } + + assert THING_FRAGMENT_WRITABLE_FIELDS.issubset(ThingFragment.Meta.fields) + + def __init__(self, thing_fragment=None, **kwargs): + self._thing_fragment = thing_fragment if thing_fragment else ThingFragment(**kwargs) + self._security = [] + self._security_definitions = {} + self._properties = {} + self._actions = {} + self._events = {} + self._init_fragment_data() + + def __getattr__(self, name): + """Search for members that raised an AttributeError in + the private ThingFragment before propagating the exception.""" + + return getattr(self._thing_fragment, name) + + def __setattr__(self, name, value): + """Setter for ThingFragment attributes.""" + + name_camel = to_camel(name) + + if name_camel not in self.THING_FRAGMENT_WRITABLE_FIELDS: + return super(Thing, self).__setattr__(name, value) + + return self._thing_fragment.__setattr__(name, value) + + def _init_fragment_data(self): + """Adds the data declared in the ThingFragment to the instance private dicts.""" + + self._security = self._thing_fragment.security + self._security_definitions = self._thing_fragment.security_definitions + + for name, prop_fragment in self._thing_fragment.properties.items(): + prop = Property(thing=self, name=name, init_dict=prop_fragment) + self.add_interaction(prop) + + for name, action_fragment in self._thing_fragment.actions.items(): + action = Action(thing=self, name=name, init_dict=action_fragment) + self.add_interaction(action) + + for name, event_fragment in self._thing_fragment.events.items(): + event = Event(thing=self, name=name, init_dict=event_fragment) + self.add_interaction(event) + + @property + def thing_fragment(self): + """The ThingFragment dictionary of this Thing.""" + + def interaction_to_json(intrct): + """Returns the JSON serialization of an Interaction instance.""" + + ret = intrct.interaction_fragment.to_dict() + + ret.update({ + "forms": [form.form_dict.to_dict() for form in intrct.forms] + }) + + return ret + + doc = self._thing_fragment.to_dict() + + doc.update({ + "properties": { + key: interaction_to_json(val) + for key, val in self.properties.items() + } + }) + + doc.update({ + "actions": { + key: interaction_to_json(val) + for key, val in self.actions.items() + } + }) + + doc.update({ + "events": { + key: interaction_to_json(val) + for key, val in self.events.items() + } + }) + + return ThingFragment(doc) + + @property + def id(self): + """Thing ID.""" + + return self.thing_fragment.id + + @property + def title(self): + """Thing title.""" + + return self.thing_fragment.title + + @property + def url_name(self): + """Returns the URL-safe name of this Thing.""" + + return slugify(self.title) + + @property + def security(self): + """List of supported security schemes.""" + + return self._security + + @property + def security_definitions(self): + """Security configuration for each of the supported protocols.""" + + return self._security_definitions + + @property + def properties(self): + """Properties interactions.""" + + return self._properties + + @property + def actions(self): + """Actions interactions.""" + + return self._actions + + @property + def events(self): + """Events interactions.""" + + return self._events + + @property + def interactions(self): + """Sequence of interactions linked to this thing.""" + + return itertools.chain( + self._properties.values(), + self._actions.values(), + self._events.values()) + + def find_interaction(self, name): + """Finds an existing Interaction by name. + The name argument may be the original name or the URL-safe version.""" + + def is_match(intrct): + return intrct.name == name or intrct.url_name == name + + return next((intrct for intrct in self.interactions if is_match(intrct)), None) + + def add_interaction(self, interaction): + """Add a new Interaction.""" + + if not isinstance(interaction, (Property, Event, Action)): + raise ValueError("Not an Interaction") + + if interaction.thing is not self: + raise ValueError("Interaction linked to another Thing") + + if self.find_interaction(interaction.name) or self.find_interaction(interaction.url_name): + raise ValueError("Duplicate Interaction: {}".format(interaction.name)) + + interaction_dict_map = { + Property: self._properties, + Action: self._actions, + Event: self._events + } + + interaction_class = next( + klass for klass in [Property, Action, Event] + if isinstance(interaction, klass)) + + interaction_dict_map[interaction_class][interaction.name] = interaction + + def remove_interaction(self, name): + """Removes an existing Interaction by name. + The name argument may be the original name or the URL-safe version.""" + + interaction = self.find_interaction(name) + + if interaction is None: + return + + self._properties.pop(interaction.name, None) + self._actions.pop(interaction.name, None) + self._events.pop(interaction.name, None) diff --git a/wotpy/wot/validation.py b/wotpy/wot/validation.py new file mode 100644 index 0000000..cf0c284 --- /dev/null +++ b/wotpy/wot/validation.py @@ -0,0 +1,1494 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Schema following the JSON Schema specification used to validate the shape of Thing Description documents. +""" + +import re + +from wotpy.wot.enums import InteractionTypes + +REGEX_SAFE_NAME = r"^[a-zA-Z0-9_-]+$" +REGEX_ANY_URI = r"^((\w+:(\/?\/?)[^\s]+)|((..\/)+)[^\s]*)$" + +"""Modifications have been made to the schema declared in the `$id` field to make forms optional by removing the relevant +`required` and `minItems` fields for the forms keyword.""" + +SCHEMA_THING = { + "title": "Thing Description", + "version": "1.1-05-September-2022", + "description": "JSON Schema for validating TD instances against the TD information model. TD instances can be with or without terms that have default values", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/w3c/wot-thing-description/main/validation/td-json-schema-validation.json", + "definitions": { + "anyUri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "descriptions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "titles": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "security": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + { + "type": "string" + } + ] + }, + "scopes": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "subprotocol": { + "type": "string", + "examples": [ + "longpoll", + "websub", + "sse" + ] + }, + "thing-context-td-uri-v1": { + "type": "string", + "const": "https://www.w3.org/2019/wot/td/v1" + }, + "thing-context-td-uri-v1.1": { + "type": "string", + "const": "https://www.w3.org/2022/wot/td/v1.1" + }, + "thing-context-td-uri-temp": { + "type": "string", + "const": "http://www.w3.org/ns/td" + }, + "thing-context": { + "anyOf": [ + { + "$comment": "New context URI with other vocabularies after it but not the old one", + "type": "array", + "items": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1.1" + } + ], + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object" + } + ], + "not": { + "$ref": "#/definitions/thing-context-td-uri-v1" + } + } + }, + { + "$comment": "Only the new context URI", + "$ref": "#/definitions/thing-context-td-uri-v1.1" + }, + { + "$comment": "Old context URI, followed by the new one and possibly other vocabularies. minItems and contains are required since prefixItems does not say all items should be provided", + "type": "array", + "prefixItems": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1" + }, + { + "$ref": "#/definitions/thing-context-td-uri-v1.1" + } + ], + "minItems": 2, + "contains": { + "$ref": "#/definitions/thing-context-td-uri-v1.1" + }, + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object" + } + ] + } + }, + { + "$comment": "Old context URI, followed by possibly other vocabularies. minItems and contains are required since prefixItems does not say all items should be provided", + "type": "array", + "prefixItems": [{ + "$ref": "#/definitions/thing-context-td-uri-v1" + }], + "minItems": 1, + "contains": { + "$ref": "#/definitions/thing-context-td-uri-v1" + }, + "additionalItems": { + "anyOf": [{ + "$ref": "#/definitions/anyUri" + }, + { + "type": "object" + } + ] + } + }, + { + "$comment": "Only the old context URI", + "$ref": "#/definitions/thing-context-td-uri-v1" + } + ] + }, + "bcp47_string": { + "type": "string", + "pattern": "^(((([A-Za-z]{2,3}(-([A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-([A-Za-z]{4}))?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-([0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(x(-[A-Za-z0-9]{1,8})+))?)|(x(-[A-Za-z0-9]{1,8})+)|((en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)))$" + }, + "type_declaration": { + "oneOf": [ + { + "type": "string", + "not": { + "const": "tm:ThingModel" + } + }, + { + "type": "array", + "items": { + "type": "string", + "not": { + "const": "tm:ThingModel" + } + } + } + ] + }, + "dataSchema-type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "dataSchema": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "title": { + "$ref": "#/definitions/title" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": True + }, + "format": { + "type": "string" + }, + "const": {}, + "default": {}, + "contentEncoding": { + "type": "string" + }, + "contentMediaType": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/dataSchema-type" + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "multipleOf": { + "$ref": "#/definitions/multipleOfDefinition" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "additionalResponsesDefinition": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "schema": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "multipleOfDefinition": { + "type": [ + "integer", + "number" + ], + "exclusiveMinimum": 0 + }, + "expectedResponse": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + }, + "form_element_base": { + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subprotocol": { + "$ref": "#/definitions/subprotocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "$ref": "#/definitions/expectedResponse" + }, + "additionalResponses": { + "$ref": "#/definitions/additionalResponsesDefinition" + } + }, + "required": [ + "href" + ], + "additionalProperties": True + }, + "form_element_property": { + "allOf": [{"$ref": "#/definitions/form_element_base"}], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + } + } + ] + } + }, + "additionalProperties": True + }, + "form_element_action": { + "allOf": [{"$ref": "#/definitions/form_element_base"}], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "invokeaction", + "queryaction", + "cancelaction" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "invokeaction", + "queryaction", + "cancelaction" + ] + } + } + ] + } + }, + "additionalProperties": True + }, + "form_element_event": { + "allOf": [{"$ref": "#/definitions/form_element_base"}], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + } + } + ] + } + }, + "additionalProperties": True + }, + "form_element_root": { + "allOf": [{"$ref": "#/definitions/form_element_base"}], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties", + "observeallproperties", + "unobserveallproperties", + "queryallactions", + "subscribeallevents", + "unsubscribeallevents" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties", + "observeallproperties", + "unobserveallproperties", + "queryallactions", + "subscribeallevents", + "unsubscribeallevents" + ] + } + } + ] + } + }, + "additionalProperties": True, + "required": ["op"] + }, + "form": { + "$comment": "This is NOT for validation purposes but for automatic generation of TS types. For more info, please see: https://github.com/w3c/wot-thing-description/pull/1319#issuecomment-994950057", + "oneOf": [ + {"$ref": "#/definitions/form_element_property"}, + {"$ref": "#/definitions/form_element_action"}, + {"$ref": "#/definitions/form_element_event"}, + {"$ref": "#/definitions/form_element_root"} + ] + }, + "property_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "items": { + "$ref": "#/definitions/form_element_property" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "observable": { + "type": "boolean" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": True + }, + "format": { + "type": "string" + }, + "const": {}, + "default": {}, + "type": { + "$ref": "#/definitions/dataSchema-type" + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "multipleOf": { + "$ref": "#/definitions/multipleOfDefinition" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": True + }, + "action_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "items": { + "$ref": "#/definitions/form_element_action" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "input": { + "$ref": "#/definitions/dataSchema" + }, + "output": { + "$ref": "#/definitions/dataSchema" + }, + "safe": { + "type": "boolean" + }, + "idempotent": { + "type": "boolean" + }, + "synchronous": { + "type": "boolean" + } + }, + "additionalProperties": True + }, + "event_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "items": { + "$ref": "#/definitions/form_element_event" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "subscription": { + "$ref": "#/definitions/dataSchema" + }, + "data": { + "$ref": "#/definitions/dataSchema" + }, + "dataResponse": { + "$ref": "#/definitions/dataSchema" + }, + "cancellation": { + "$ref": "#/definitions/dataSchema" + } + }, + "additionalProperties": True + }, + "base_link_element": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "type": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "anchor": { + "$ref": "#/definitions/anyUri" + }, + "hreflang": { + "anyOf": [ + {"$ref": "#/definitions/bcp47_string"}, + { + "type": "array", + "items": { + "$ref": "#/definitions/bcp47_string" + } + } + ] + } + }, + "required": [ + "href" + ], + "additionalProperties": True + }, + "link_element": { + "allOf": [ + { + "$ref": "#/definitions/base_link_element" + }, + { + "not": { + "description": "A basic link element should not contain sizes", + "type": "object", + "properties": { + "sizes": {} + }, + "required": [ + "sizes" + ] + } + }, + { + "not": { + "description": "A basic link element should not contain icon or tm:extends", + "properties": { + "rel": { + "enum": [ + "icon", + "tm:extends" + ] + } + }, + "required": [ + "rel" + ] + } + } + ] + }, + "icon_link_element": { + "allOf": [ + { + "$ref": "#/definitions/base_link_element" + }, + { + "properties": { + "rel": { + "const": "icon" + }, + "sizes": { + "type": "string", + "pattern": "[0-9]*x[0-9]+" + } + }, + "required": [ + "rel" + ] + } + ] + }, + "additionalSecurityScheme": { + "description": "Applies to additional SecuritySchemes not defined in the WoT TD specification.", + "$comment": "Additional SecuritySchemes should always be defined via a context extension, using a prefixed value for the scheme. This prefix (e.g. 'ace', see the example below) must contain at least one character in order to reference a valid JSON-LD context extension.", + "examples": [ + { + "scheme": "ace:ACESecurityScheme", + "ace:as": "coaps://as.example.com/token", + "ace:audience": "coaps://rs.example.com", + "ace:scopes": ["limited", "special"], + "ace:cnonce": True + } + ], + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "pattern": ".+:.*" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "noSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "nosec" + ] + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "autoSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "auto" + ] + } + }, + "not": { + "required": ["name"] + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "comboSecurityScheme": { + "oneOf": [ + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "combo" + ] + }, + "oneOf": { + "type": "array", + "minItems": 2, + "items": { + "type": "string" + } + } + }, + "required": [ + "scheme", + "oneOf" + ], + "additionalProperties": True + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "combo" + ] + }, + "allOf": { + "type": "array", + "minItems": 2, + "items": { + "type": "string" + } + } + }, + "required": [ + "scheme", + "allOf" + ], + "additionalProperties": True + } + ] + }, + "basicSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "basic" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "digestSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "digest" + ] + }, + "qop": { + "type": "string", + "enum": [ + "auth", + "auth-int" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "apiKeySecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "apikey" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "uri", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "bearerSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "alg": { + "type": "string" + }, + "format": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "pskSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "psk" + ] + }, + "identity": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "oAuth2SecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "token": { + "$ref": "#/definitions/anyUri" + }, + "refresh": { + "$ref": "#/definitions/anyUri" + }, + "scopes": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "flow": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [ + "code", + "client", + "device" + ] + } + ] + } + }, + "required": [ + "scheme" + ], + "additionalProperties": True + }, + "securityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/noSecurityScheme" + }, + { + "$ref": "#/definitions/autoSecurityScheme" + }, + { + "$ref": "#/definitions/comboSecurityScheme" + }, + { + "$ref": "#/definitions/basicSecurityScheme" + }, + { + "$ref": "#/definitions/digestSecurityScheme" + }, + { + "$ref": "#/definitions/apiKeySecurityScheme" + }, + { + "$ref": "#/definitions/bearerSecurityScheme" + }, + { + "$ref": "#/definitions/pskSecurityScheme" + }, + { + "$ref": "#/definitions/oAuth2SecurityScheme" + }, + { + "$ref": "#/definitions/additionalSecurityScheme" + } + ] + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uri" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/property_element" + } + }, + "actions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/action_element" + } + }, + "events": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/event_element" + } + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "version": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + }, + "links": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/link_element" + }, + { + "$ref": "#/definitions/icon_link_element" + } + ] + } + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_root" + } + }, + "base": { + "$ref": "#/definitions/anyUri" + }, + "securityDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/securityScheme" + } + }, + "schemaDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "support": { + "$ref": "#/definitions/anyUri" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "modified": { + "type": "string", + "format": "date-time" + }, + "profile": { + "oneOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/anyUri" + } + } + ] + }, + "security": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "@context": { + "$ref": "#/definitions/thing-context" + } + }, + "required": [ + "title", + "security", + "securityDefinitions", + "@context" + ], + "additionalProperties": True +} + + +def is_valid_uri(val): + """Returns True if the given value is a valid URI.""" + + return False if re.match(REGEX_ANY_URI, val) is None else True + + +def is_valid_safe_name(val): + """Returns True if the given value is a safe machine-readable name.""" + + return False if re.match(REGEX_SAFE_NAME, val) is None else True + + +class InvalidDescription(Exception): + """Exception raised when a document for an object + in the TD hierarchy has an invalid format.""" + + pass diff --git a/wotpy/wot/wot.py b/wotpy/wot/wot.py new file mode 100644 index 0000000..b5afe69 --- /dev/null +++ b/wotpy/wot/wot.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python + +# Copyright (c) 2018 CTIC Centro Tecnologico +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + +# -*- coding: utf-8 -*- + +""" +Class that serves as the WoT entrypoint. +""" + +import asyncio +import json +import logging +import warnings + +import reactivex +from tornado.httpclient import AsyncHTTPClient, HTTPRequest + +from wotpy.support import is_dnssd_supported +from wotpy.utils.utils import handle_observer_finalization +from wotpy.wot.consumed.thing import ConsumedThing +from wotpy.wot.dictionaries.thing import ThingFragment +from wotpy.wot.enums import DiscoveryMethod +from wotpy.wot.exposed.thing import ExposedThing +from wotpy.wot.td import ThingDescription +from wotpy.wot.thing import Thing + +DEFAULT_FETCH_TIMEOUT_SECS = 20.0 + + +class WoT: + """The WoT object is the API entry point and it is exposed by an + implementation of the WoT Runtime. The WoT object does not expose + properties, only methods for discovering, consuming and exposing a Thing.""" + + def __init__(self, servient): + self._servient = servient + self._logr = logging.getLogger(__name__) + + @property + def servient(self): + """Servient instance of this WoT entrypoint.""" + + return self._servient + + @classmethod + def _is_fragment_match(cls, item, thing_filter): + """Returns True if the given item (an ExposedThing, Thing or TD) + matches the fragment in the given Thing filter.""" + + td = None + + if isinstance(item, ExposedThing): + td = ThingDescription.from_thing(item.thing) + elif isinstance(item, Thing): + td = ThingDescription.from_thing(item) + elif isinstance(item, ThingDescription): + td = item + + assert td + + fragment_dict = thing_filter.fragment if thing_filter.fragment else {} + + return all( + item in td.to_dict().items() + for item in fragment_dict.items()) + + def _build_local_discover_observable(self, thing_filter): + """Builds an Observable to discover Things using the local method.""" + + found_tds = [ + ThingDescription.from_thing(exposed_thing.thing).to_str() + for exposed_thing in self._servient.exposed_things + if self._is_fragment_match(exposed_thing, thing_filter) + ] + + # noinspection PyUnresolvedReferences + return reactivex.of(*found_tds) + + def _build_dnssd_discover_observable(self, thing_filter, dnssd_find_kwargs): + """Builds an Observable to discover Things using the multicast method based on DNS-SD.""" + + if not is_dnssd_supported(): + warnings.warn("Unsupported DNS-SD multicast discovery") + # noinspection PyUnresolvedReferences + return reactivex.empty() + + dnssd_find_kwargs = dnssd_find_kwargs if dnssd_find_kwargs else {} + + if not self._servient.dnssd: + # noinspection PyUnresolvedReferences + return reactivex.empty() + + def subscribe(observer, scheduler): + """Browses the Servient services using DNS-SD and retrieves the TDs that match the filters.""" + + state = {"stop": False} + + @handle_observer_finalization(observer) + async def callback(): + address_port_pairs = await self._servient.dnssd.find(**dnssd_find_kwargs) + + def build_pair_url(idx, path=None): + addr, port = address_port_pairs[idx] + base = "http://{}:{}".format(addr, port) + path = path if path else '' + return "{}/{}".format(base, path.strip("/")) + + http_client = AsyncHTTPClient() + + catalogue_resps = [ + http_client.fetch(build_pair_url(idx)) + for idx in range(len(address_port_pairs)) + ] + + for idx, future in enumerate(catalogue_resps): + if state["stop"]: + return + try: + catalogue_resp = await future + except Exception as ex: + self._logr.warning( + "Exception on HTTP request to TD catalogue: {}".format(ex)) + else: + catalogue = json.loads(catalogue_resp.body) + + if state["stop"]: + return + + td_resps = [ + await http_client.fetch(build_pair_url( + #wait_iter.current_index, path=path)) + idx, path=path)) + for thing_id, path in catalogue.items() + ] + + tds = [ + ThingDescription(td_resp.body) + for td_resp in td_resps + ] + + tds_filtered = [ + td for td in tds if self._is_fragment_match(td, thing_filter)] + + [observer.on_next(td.to_str()) for td in tds_filtered] + + def unsubscribe(): + state["stop"] = True + + loop = asyncio.get_running_loop() + loop.create_task(callback()) + + return unsubscribe + + # noinspection PyUnresolvedReferences + return reactivex.create(subscribe) + + def discover(self, thing_filter, dnssd_find_kwargs=None): + """Starts the discovery process that will provide ThingDescriptions + that match the optional argument filter of type ThingFilter.""" + + supported_methods = [ + DiscoveryMethod.ANY, + DiscoveryMethod.LOCAL, + DiscoveryMethod.MULTICAST + ] + + if thing_filter.method not in supported_methods: + err = NotImplementedError("Unsupported discovery method") + # noinspection PyUnresolvedReferences + return reactivex.throw(err) + + if thing_filter.query: + err = NotImplementedError( + "Queries are not supported yet (please use filter.fragment)") + # noinspection PyUnresolvedReferences + return reactivex.throw(err) + + observables = [] + + if thing_filter.method in [DiscoveryMethod.ANY, DiscoveryMethod.LOCAL]: + observables.append( + self._build_local_discover_observable(thing_filter)) + + if thing_filter.method in [DiscoveryMethod.ANY, DiscoveryMethod.MULTICAST]: + observables.append(self._build_dnssd_discover_observable( + thing_filter, dnssd_find_kwargs)) + + # noinspection PyUnresolvedReferences + return reactivex.merge(*observables) + + @classmethod + async def fetch(cls, url, timeout_secs=None): + """Accepts an url argument and returns a Future + that resolves with a Thing Description string.""" + + timeout_secs = timeout_secs or DEFAULT_FETCH_TIMEOUT_SECS + + http_client = AsyncHTTPClient() + http_request = HTTPRequest(url, request_timeout=timeout_secs) + + http_response = await http_client.fetch(http_request) + + td_doc = json.loads(http_response.body) + td = ThingDescription(td_doc) + + return td.to_str() + + def consume(self, td_str): + """Accepts a thing description string argument and returns a + ConsumedThing object instantiated based on that description.""" + + td = ThingDescription(td_str) + + return ConsumedThing(servient=self._servient, td=td) + + @classmethod + def thing_from_model(cls, model): + """Takes a ThingModel and builds a Thing. + Raises if the model has an unexpected type.""" + + expected_types = (str, ThingFragment, ConsumedThing) + + if not isinstance(model, expected_types): + raise ValueError("Expected one of: {}".format(expected_types)) + + if isinstance(model, str): + thing = ThingDescription(doc=model).build_thing() + elif isinstance(model, ThingFragment): + thing = Thing(thing_fragment=model) + else: + thing = model.td.build_thing() + + return thing + + def produce(self, model): + """Accepts a model argument of type ThingModel and returns an ExposedThing + object, locally created based on the provided initialization parameters.""" + + thing = self.thing_from_model(model) + exposed_thing = ExposedThing(servient=self._servient, thing=thing) + self._servient.add_exposed_thing(exposed_thing) + + return exposed_thing + + async def produce_from_url(self, url, timeout_secs=None): + """Return a Future that resolves to an ExposedThing created + from the thing description retrieved from the given URL.""" + + td_str = await self.fetch(url, timeout_secs=timeout_secs) + exposed_thing = self.produce(td_str) + + return exposed_thing + + async def consume_from_url(self, url, timeout_secs=None): + """Return a Future that resolves to a ConsumedThing created + from the thing description retrieved from the given URL.""" + + td_str = await self.fetch(url, timeout_secs=timeout_secs) + consumed_thing = self.consume(td_str) + + return consumed_thing + + async def register(self, directory, thing): + """Generate the Thing Description as td, given the Properties, + Actions and Events defined for this ExposedThing object. + Then make a request to register td to the given WoT Thing Directory.""" + + raise NotImplementedError() + + async def unregister(self, directory, thing): + """Makes a request to unregister the thing from the given WoT Thing Directory.""" + + raise NotImplementedError()