├── .tool-versions ├── .gitignore ├── examples └── python │ ├── crc.py │ ├── _tests │ └── test_cobs.py │ ├── cobs.py │ ├── messages.py │ └── app.py ├── docs ├── source │ ├── _templates │ │ └── layout.html │ ├── index.rst │ ├── _static │ │ └── css │ │ │ └── custom.css │ ├── conf.py │ ├── glossary.rst │ ├── connect.rst │ ├── ext │ │ ├── role_enum.py │ │ └── directive_message.py │ ├── enums.rst │ ├── encoding.rst │ └── messages.rst ├── Makefile └── make.bat ├── README.md ├── requirements.txt ├── .github └── workflows │ └── sphinx.yml └── LICENSE /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.12.1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | build -------------------------------------------------------------------------------- /examples/python/crc.py: -------------------------------------------------------------------------------- 1 | from binascii import crc32 as _crc32 2 | 3 | 4 | def crc(data: bytes, seed=0, align=4): 5 | """ 6 | Calculate the CRC32 of data with an optional seed and alignment. 7 | """ 8 | remainder = len(data) % align 9 | if remainder: 10 | data += b"\x00" * (align - remainder) 11 | return _crc32(data, seed) 12 | -------------------------------------------------------------------------------- /docs/source/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {% block menu %} 4 | {{ super() }} 5 | 6 |

7 | Appendix 8 |

9 | 13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LEGO® Education SPIKE™ Prime protocol documentation 2 | 3 | This repository contains the technical documentation for the communication 4 | protocol of the LEGO® Education SPIKE™ Prime hub. 5 | 6 | The documentation is written in [reStructuredText](https://docutils.sourceforge.io/rst.html), 7 | built with [Sphinx](https://www.sphinx-doc.org/), and hosted on GitHub Pages. 8 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome 2 | ####### 3 | 4 | This documentation describes the communication protocol for 5 | LEGO® Education SPIKE™ App 3 Prime hubs. 6 | It is intended for developers who want to write their own software 7 | to control the SPIKE™ Prime Hub over Bluetooth Low Energy (BLE). 8 | 9 | Some sections include example implementations for reference, 10 | written in Python. 11 | The full source code is available in the repository at 12 | :repo:`examples/python`. 13 | 14 | .. toctree:: 15 | :caption: Topics 16 | :maxdepth: 1 17 | 18 | connect 19 | enums 20 | messages 21 | encoding 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.16 2 | Babel==2.14.0 3 | black==24.1.1 4 | bleak==0.21.1 5 | certifi==2023.11.17 6 | charset-normalizer==3.3.2 7 | click==8.1.7 8 | colorama==0.4.6 9 | dbus-fast==2.21.1 10 | docutils==0.20.1 11 | idna==3.6 12 | imagesize==1.4.1 13 | Jinja2==3.1.3 14 | livereload==2.6.3 15 | MarkupSafe==2.1.4 16 | mypy-extensions==1.0.0 17 | packaging==23.2 18 | pathspec==0.12.1 19 | platformdirs==4.2.0 20 | Pygments==2.17.2 21 | requests==2.31.0 22 | six==1.16.0 23 | snowballstemmer==2.2.0 24 | Sphinx==7.2.6 25 | sphinx-autobuild==2021.3.14 26 | sphinx-rtd-theme==2.0.0 27 | sphinxcontrib-applehelp==1.0.8 28 | sphinxcontrib-devhelp==1.0.6 29 | sphinxcontrib-htmlhelp==2.0.5 30 | sphinxcontrib-jquery==4.1 31 | sphinxcontrib-jsmath==1.0.1 32 | sphinxcontrib-qthelp==1.0.7 33 | sphinxcontrib-serializinghtml==1.1.10 34 | tornado==6.4 35 | urllib3==2.1.0 36 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --table-head-bg: #2980B9; 3 | --table-head-fg: #FFFFFF; 4 | --table-row-bg-even: #EEE; 5 | --table-row-bg-odd: #CCC; 6 | } 7 | 8 | 9 | .rst-content table.docutils { 10 | width: 100%; 11 | 12 | .head { 13 | background-color: var(--table-head-bg); 14 | 15 | p, 16 | a { 17 | color: var(--table-head-fg); 18 | } 19 | 20 | } 21 | 22 | tr td { 23 | white-space: unset; 24 | } 25 | 26 | } 27 | 28 | .indent-remaining, 29 | .indent-remaining~* { 30 | margin-inline-start: 10%; 31 | } 32 | 33 | .rst-content section.device-message table .head { 34 | filter: invert() 35 | } 36 | 37 | .rst-content table.message-quickref tr { 38 | 39 | .literal { 40 | font-size: unset; 41 | } 42 | 43 | >:nth-child(-n+2) { 44 | text-align: center; 45 | } 46 | 47 | } 48 | 49 | .rst-content code.literal.enum-val, 50 | .rst-content table.docutils>tbody>tr code.literal { 51 | font-size: unset; 52 | font-weight: bold; 53 | } 54 | 55 | .rst-content table.docutils:not(.field-list) { 56 | 57 | caption { 58 | display: none; 59 | } 60 | 61 | tr.row-even td { 62 | background-color: var(--table-row-bg-even); 63 | } 64 | 65 | tr.row-odd td { 66 | background-color: var(--table-row-bg-odd); 67 | } 68 | 69 | } 70 | 71 | .rst-content table.first-col-right tr>:first-child { 72 | text-align: right; 73 | } -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "SPIKE™ Prime protocol" 10 | copyright = "2024, LEGO® Education" 11 | author = "LEGO® Education" 12 | release = "1.0" 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | keep_warnings = True 18 | primary_domain = "std" 19 | 20 | import sys, os 21 | 22 | sys.path.append(os.path.abspath("ext")) 23 | 24 | extensions = [ 25 | "sphinx.ext.autosectionlabel", 26 | "sphinx.ext.extlinks", 27 | "sphinx.ext.githubpages", 28 | "sphinx_rtd_theme", 29 | "role_enum", 30 | "directive_message", 31 | ] 32 | 33 | templates_path = ["_templates"] 34 | exclude_patterns = [] 35 | 36 | extlinks = { 37 | "repo": ( 38 | "https://github.com/LEGO/spike-prime-docs/tree/main/%s", 39 | "https://github.com/LEGO/spike-prime-docs/%s", 40 | ), 41 | } 42 | 43 | # -- Options for HTML output ------------------------------------------------- 44 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 45 | 46 | html_theme = "sphinx_rtd_theme" 47 | html_static_path = ["_static"] 48 | html_css_files = ["css/custom.css"] 49 | 50 | html_theme_options = { 51 | "prev_next_buttons_location": None, 52 | "collapse_navigation": False, 53 | } 54 | 55 | html_copy_source = False 56 | -------------------------------------------------------------------------------- /.github/workflows/sphinx.yml: -------------------------------------------------------------------------------- 1 | # Build and deploy docs to GitHub Pages 2 | name: Deploy docs to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4.1.1 30 | - name: Configure GitHub Pages 31 | id: pages 32 | uses: actions/configure-pages@v4.0.0 33 | - name: Get tool versions 34 | id: tool_versions 35 | run: echo "python=$(grep --only-matching --perl-regexp 'python \K\S+' .tool-versions)" >> $GITHUB_OUTPUT 36 | - name: Setup Python 37 | uses: actions/setup-python@v5.0.0 38 | with: 39 | python-version: ${{ steps.tool_versions.outputs.python }} 40 | - name: Install dependencies 41 | run: pip install -r requirements.txt 42 | - name: make html 43 | run: | 44 | cd docs 45 | make html 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v3.0.1 48 | with: 49 | path: docs/build/html 50 | 51 | deploy: 52 | environment: 53 | name: github-pages 54 | url: ${{ steps.deployment.outputs.page_url }} 55 | runs-on: ubuntu-latest 56 | needs: build 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v4.0.4 61 | -------------------------------------------------------------------------------- /docs/source/glossary.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Glossary 4 | ######## 5 | 6 | .. glossary:: 7 | :sorted: 8 | 9 | array 10 | An array of values of the same type, in the format ``[?]``. 11 | If ```` is omitted, then the array is of variable size (typically given in a preceding field). 12 | 13 | COBS 14 | Consistent Overhead Byte Stuffing (COBS) is an algorithm for encoding data such that the encoded data 15 | does not contain any delimiter bytes. 16 | 17 | In the context of SPIKE™ Prime, COBS is used to replace bytes with a value of ``0x02`` and below 18 | with another byte that does not have a value of ``0x02`` or below. 19 | For more details, see :ref:`Encoding: COBS `. 20 | 21 | CRC32 22 | CRC-32 (32bit Cyclic Redundancy Check) is a checksum algorithm used to detect errors in data. 23 | For SPIKE™ Prime, the CRC must be calculated on a multiple of 4 bytes. 24 | 25 | For data that is not a multiple of 4 bytes, append ``0x00`` until the data is a multiple of 4 bytes 26 | before calculating the CRC. 27 | 28 | hub 29 | SPIKE™ Prime hub. 30 | 31 | int 32 | An integer in the format ``u?int\d+``. 33 | The ``u``-prefix indicates whether the integer is signed (if omitted) or unsigned (if present). 34 | The number after ``int`` is the number of bits. 35 | (e.g., ``int8`` is an 8-bit signed integer, ``uint32`` is a 32-bit unsigned integer) 36 | 37 | null-terminated string 38 | A character string terminated with ``NUL`` (``0x00``), given in the format ``string[]``, 39 | where ```` is the maximum length of the string (including the terminating ``NUL``). 40 | 41 | .. attention:: 42 | Strings **must** be terminated with ``NUL``, so the effective length of the string is `` - 1``. 43 | 44 | program slot 45 | One of the 20 program slots on the hub, indexed from 0 to 19. 46 | 47 | smart coast/brake 48 | A method of stopping a motor, while attempting to compensate for inaccuracies in following commands. 49 | 50 | 51 | -------------------------------------------------------------------------------- /docs/source/connect.rst: -------------------------------------------------------------------------------- 1 | Setting up the connection 2 | ######################### 3 | 4 | 5 | BLE capabilities of the hub 6 | *************************** 7 | 8 | The LEGO® SPIKE™ Prime Hub exposes a BLE GATT service containing two 9 | characteristics: one for receiving data (:dfn:`RX`), 10 | and one for transmitting data (:dfn:`TX`). 11 | 12 | The table below shows the UUIDs for the service and characteristics. 13 | 14 | .. list-table:: 15 | :header-rows: 1 16 | :stub-columns: 1 17 | :widths: 1 5 18 | :class: first-col-right 19 | 20 | * - Item 21 | - UUID 22 | * - Service 23 | - :samp:`0000FD02-000{0}-1000-8000-00805F9B34FB` 24 | * - RX 25 | - :samp:`0000FD02-000{1}-1000-8000-00805F9B34FB` 26 | * - TX 27 | - :samp:`0000FD02-000{2}-1000-8000-00805F9B34FB` 28 | 29 | .. note:: 30 | "Receiving" and "transmitting" are from the perspective of the hub. 31 | 32 | The hub includes the service UUID in the advertisement data, 33 | so that it can be used to filter scan results. 34 | 35 | To send data to the hub, perform a **write-without-response** 36 | operation on the hub's RX characteristic. 37 | 38 | Any data from the hub will be delivered as a notification on the TX 39 | characteristic. 40 | 41 | .. hint:: Make sure to enable notifications on the TX characteristic. 42 | 43 | 44 | Handshake and negotiation 45 | ************************* 46 | 47 | Upon connecting, the client should always initiate communication 48 | by sending an :ref:`InfoRequest ` to the hub. 49 | The hub will respond with an :ref:`InfoResponse `, 50 | detailing the capabilities of the hub. 51 | 52 | Of particular interest are the maximum sizes for packets and chunks: 53 | 54 | :Max. packet size: 55 | The largest amount of data that can be written to the RX characteristic 56 | in a single operation. 57 | 58 | :Max. chunk size: 59 | The maximum number of bytes allowed in the payload of a 60 | :ref:`TransferChunkRequest `. 61 | 62 | The examples below show how these limits may be applied in Python: 63 | 64 | .. literalinclude:: /../../examples/python/app.py 65 | :caption: Honoring the maximum packet size 66 | :dedent: 67 | :start-at: async def send_message 68 | :end-at: await client.write 69 | 70 | .. literalinclude:: /../../examples/python/app.py 71 | :caption: Using the maximum chunk size 72 | :dedent: 73 | :start-at: running_crc = 0 74 | :end-before: if not chunk_response.success 75 | -------------------------------------------------------------------------------- /docs/source/ext/role_enum.py: -------------------------------------------------------------------------------- 1 | from docutils import nodes 2 | from sphinx import addnodes 3 | from sphinx.domains.index import IndexRole 4 | from sphinx.util.nodes import process_index_entry 5 | 6 | 7 | def make_index(id: str, *entries: str): 8 | node = addnodes.index() 9 | node["entries"] = [] 10 | for entry in entries: 11 | node["entries"].extend(process_index_entry(entry, id)) 12 | return node 13 | 14 | 15 | class EnumRole(IndexRole): 16 | """ 17 | Utility role to assist in enumeration indexing within ``.. list-table``. 18 | 19 | .. note:: 20 | 21 | The ``.. list-table`` approach should preferably be replaced 22 | with a custom directive, and the directive should handle the indexing. 23 | """ 24 | 25 | def _ensure_header(self, section) -> str: 26 | """Ensure that the enum category is included in the index and return its name.""" 27 | title_node = section[0] 28 | if not isinstance(title_node, nodes.title): 29 | raise ValueError(f"Expected title node, got {title_node!r}") 30 | 31 | title = title_node.astext() 32 | prev_node = section.previous_sibling() 33 | if ( 34 | isinstance(prev_node, addnodes.index) 35 | and prev_node["ids"][0] == section["ids"][0] 36 | ): 37 | return title 38 | 39 | # add index node 40 | index = make_index( 41 | section["ids"][0], 42 | f"single: {title}", 43 | f"single: Enumerations; {title}", 44 | ) 45 | section.replace_self([index, section]) 46 | return title 47 | 48 | def run(self): 49 | section_node = None 50 | for s in self.inliner.document._fast_findall(nodes.section): 51 | section_node = s 52 | header = self._ensure_header(section_node) 53 | title = self.title 54 | target_id = f"enum-{nodes.make_id(header)}_{nodes.make_id(title)}" 55 | target_node = nodes.target("", "", ids=[target_id]) 56 | self.inliner.document.note_explicit_target(target_node) 57 | index_node = make_index( 58 | target_id, 59 | f"single: {header}; {title}", 60 | ) 61 | index_node["inline"] = True 62 | 63 | self.set_source_info(target_node) 64 | self.set_source_info(index_node) 65 | text = nodes.inline(title, title, classes=["enum-entry"]) 66 | return [index_node, target_node, text], [] 67 | 68 | 69 | import sphinx.application 70 | 71 | 72 | def setup(app: sphinx.application.Sphinx): 73 | app.add_role("enum", EnumRole()) 74 | -------------------------------------------------------------------------------- /examples/python/_tests/test_cobs.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | 4 | sys.path.append("..") 5 | from cobs import encode, decode, pack, unpack 6 | 7 | # fmt: off 8 | TEST_CASES = ( 9 | ( 10 | # Contains 0,1,2 to escape 11 | bytes(( 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 84, 234, 54, 0, 45, 23, 12 )), 12 | bytes((0, 84, 168, 4, 0, 7, 6, 5, 84, 168, 10, 0, 7, 6, 87, 233, 53, 5, 46, 20, 15, 2)), 13 | ), 14 | ( 15 | # Contains nothing to escape 16 | bytes(( 255, 254, 223, 213, 125, 175, 100, 97, 54, 21, 65, 45 )), 17 | bytes((12, 252, 253, 220, 214, 126, 172, 103, 98, 53, 22, 66, 46, 2)), 18 | ), 19 | ( 20 | # Complex case with multiple escape sequences 21 | bytes(( 10, 3, 0, 61, 91, 151, 0, 185, 217, 87, 112, 195, 221, 207, 216, 40, 63, 220, 253, 42, 248, 85, 195, 175, 6, 126, 181, 50, 23, 174, 250, 255, 3, 183, 30, 224, 14, 2, 199, 86, 57, 227, 0, 242, 234, 255, 194, 243, 107, 162, 105, 235, 251, 177, 77, 73, 93, 187, 122, 149, 235, 171, 213, 7, 93, 177, 79, 179, 43, 244, 0, 49, 243, 10, 46, 211, 18, 98, 107, 69, 134, 138, 196, 19, 134, 96, 95, 140, 54, 149, 187, 149, 27, 70, 216, 79, 117, 5, 123, 237, 249, 196, 207, 167, 114, 54, 231, 166, 213, 205, 203, 118, 61, 224, 118, 89, 107, 44, 11, 141, 68, 108, 23, 91, 25, 18, 71, 42, 50, 212, 151, 74, 76, 136, 150, 152, 28, 45, 145, 190, 172, 224, 129, 163, 82, 162, 237, 181, 71, 111, 92, 154, 178, 208, 0, 101, 108, 80, 11, 173, 33, 94, 5, 253, 183, 192, 14, 215, 22, 218, 127, 245, 41, 117, 107, 31, 117, 44 )), 22 | bytes((6, 9, 0, 5, 62, 88, 148, 202, 186, 218, 84, 115, 192, 222, 204, 219, 43, 60, 223, 254, 41, 251, 86, 192, 172, 5, 125, 182, 49, 20, 173, 249, 252, 0, 180, 29, 227, 13, 4, 196, 85, 58, 224, 29, 241, 233, 252, 193, 240, 104, 161, 106, 232, 248, 178, 78, 74, 94, 184, 121, 150, 232, 168, 214, 4, 94, 178, 76, 176, 40, 247, 85, 50, 240, 9, 45, 208, 17, 97, 104, 70, 133, 137, 199, 16, 133, 99, 92, 143, 53, 150, 184, 150, 24, 69, 219, 76, 118, 6, 120, 238, 250, 199, 204, 164, 113, 53, 228, 165, 214, 206, 200, 117, 62, 227, 117, 90, 104, 47, 8, 142, 71, 111, 20, 88, 26, 17, 68, 41, 49, 215, 148, 73, 79, 139, 149, 155, 31, 46, 146, 189, 175, 227, 130, 160, 81, 161, 238, 182, 68, 108, 95, 153, 177, 211, 25, 102, 111, 83, 8, 174, 34, 93, 6, 254, 180, 195, 13, 212, 21, 217, 124, 246, 42, 118, 104, 28, 118, 47, 2)), 23 | ), 24 | ) 25 | # fmt: on 26 | 27 | 28 | class TestCobs(unittest.TestCase): 29 | 30 | def test_encode_decode(self): 31 | data = b"Hello, World!" 32 | encoded = encode(data) 33 | decoded = decode(encoded) 34 | self.assertEqual(decoded, data) 35 | 36 | def test_pack_unpack(self): 37 | data = b"Hello, World!" 38 | packed = pack(data) 39 | unpacked = unpack(packed) 40 | self.assertEqual(unpacked, data) 41 | 42 | def test_pack_cases(self): 43 | for data, expected in TEST_CASES: 44 | with self.subTest(data=data, expected=expected): 45 | self.assertEqual(pack(data), expected) 46 | 47 | def test_unpack_cases(self): 48 | for expected, data in TEST_CASES: 49 | with self.subTest(data=data, expected=expected): 50 | self.assertEqual(unpack(data), expected) 51 | 52 | 53 | if __name__ == "__main__": 54 | unittest.main() 55 | -------------------------------------------------------------------------------- /examples/python/cobs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example implementation of the Consistent Overhead Byte Stuffing (COBS) algorithm 3 | used by the SPIKE™ Prime BLE protocol. 4 | 5 | This implementation prioritizes readability and simplicity over performance and 6 | should be used for educational purposes only. 7 | """ 8 | 9 | DELIMITER = 0x02 10 | """Delimiter used to mark end of frame""" 11 | 12 | NO_DELIMITER = 0xFF 13 | """Code word indicating no delimiter in block""" 14 | 15 | COBS_CODE_OFFSET = DELIMITER 16 | """Offset added to code word""" 17 | 18 | MAX_BLOCK_SIZE = 84 19 | """Maximum block size (incl. code word)""" 20 | 21 | XOR = 3 22 | """XOR mask for encoding""" 23 | 24 | 25 | def encode(data: bytes): 26 | """ 27 | Encode data using COBS algorithm, such that no delimiters are present. 28 | """ 29 | buffer = bytearray() 30 | code_index = block = 0 31 | def begin_block(): 32 | """Append code word to buffer and update code_index and block""" 33 | nonlocal code_index, block 34 | code_index = len(buffer) # index of incomplete code word 35 | buffer.append(NO_DELIMITER) # updated later if delimiter is encountered 36 | block = 1 # no. of bytes in block (incl. code word) 37 | 38 | begin_block() 39 | for byte in data: 40 | if byte > DELIMITER: 41 | # non-delimeter value, write as-is 42 | buffer.append(byte) 43 | block += 1 44 | 45 | if byte <= DELIMITER or block > MAX_BLOCK_SIZE: 46 | # block completed because size limit reached or delimiter found 47 | if byte <= DELIMITER: 48 | # reason for block completion is delimiter 49 | # update code word to reflect block size 50 | delimiter_base = byte * MAX_BLOCK_SIZE 51 | block_offset = block + COBS_CODE_OFFSET 52 | buffer[code_index] = delimiter_base + block_offset 53 | # begin new block 54 | begin_block() 55 | 56 | # update final code word 57 | buffer[code_index] = block + COBS_CODE_OFFSET 58 | 59 | return buffer 60 | 61 | 62 | def decode(data: bytes): 63 | """ 64 | Decode data using COBS algorithm. 65 | """ 66 | buffer = bytearray() 67 | 68 | def unescape(code: int): 69 | """Decode code word, returning value and block size""" 70 | if code == 0xFF: 71 | # no delimiter in block 72 | return None, MAX_BLOCK_SIZE + 1 73 | value, block = divmod(code - COBS_CODE_OFFSET, MAX_BLOCK_SIZE) 74 | if block == 0: 75 | # maximum block size ending with delimiter 76 | block = MAX_BLOCK_SIZE 77 | value -= 1 78 | return value, block 79 | 80 | value, block = unescape(data[0]) 81 | for byte in data[1:]: # first byte already processed 82 | block -= 1 83 | if block > 0: 84 | buffer.append(byte) 85 | continue 86 | 87 | # block completed 88 | if value is not None: 89 | buffer.append(value) 90 | 91 | value, block = unescape(byte) 92 | 93 | return buffer 94 | 95 | 96 | def pack(data: bytes): 97 | """ 98 | Encode and frame data for transmission. 99 | """ 100 | buffer = encode(data) 101 | 102 | # XOR buffer to remove problematic ctrl+C 103 | for i in range(len(buffer)): 104 | buffer[i] ^= XOR 105 | 106 | # add delimiter 107 | buffer.append(DELIMITER) 108 | return bytes(buffer) 109 | 110 | 111 | def unpack(frame: bytes): 112 | """ 113 | Unframe and decode frame. 114 | """ 115 | start = 0 116 | if frame[0] == 0x01: # unused priority byte 117 | start += 1 118 | # unframe and XOR 119 | unframed = bytes(map(lambda x: x ^ XOR, frame[start:-1])) 120 | return bytes(decode(unframed)) 121 | -------------------------------------------------------------------------------- /docs/source/enums.rst: -------------------------------------------------------------------------------- 1 | .. index:: ! Enumerations 2 | 3 | .. role:: val(code) 4 | :class: enum-val 5 | 6 | Enumerations 7 | ############ 8 | 9 | Product Group Device 10 | ******************** 11 | 12 | .. list-table:: 13 | :header-rows: 1 14 | :widths: 1 4 15 | :class: first-col-right 16 | 17 | + - Value (:term:`uint16 `) 18 | - Description 19 | 20 | + - :val:`0x0000` 21 | - :enum:`SPIKE™ Prime` 22 | 23 | Color 24 | ***** 25 | 26 | .. list-table:: 27 | :header-rows: 1 28 | :widths: 1 4 29 | :class: first-col-right 30 | 31 | + - Value (:term:`int8 `) 32 | - Description 33 | 34 | + - :val:`0x00` 35 | - :enum:`Black` 36 | 37 | + - :val:`0x01` 38 | - :enum:`Magenta` 39 | 40 | + - :val:`0x02` 41 | - :enum:`Purple` 42 | 43 | + - :val:`0x03` 44 | - :enum:`Blue` 45 | 46 | + - :val:`0x04` 47 | - :enum:`Azure` 48 | 49 | + - :val:`0x05` 50 | - :enum:`Turquoise` 51 | 52 | + - :val:`0x06` 53 | - :enum:`Green` 54 | 55 | + - :val:`0x07` 56 | - :enum:`Yellow` 57 | 58 | + - :val:`0x08` 59 | - :enum:`Orange` 60 | 61 | + - :val:`0x09` 62 | - :enum:`Red` 63 | 64 | + - :val:`0x0A` 65 | - :enum:`White` 66 | 67 | + - :val:`0xFF` 68 | - :enum:`Unknown` or no color detected 69 | 70 | 71 | Hub Port 72 | ******** 73 | 74 | .. list-table:: 75 | :header-rows: 1 76 | :widths: 1 4 77 | :class: first-col-right 78 | 79 | + - Value (:term:`uint8 `) 80 | - Description 81 | 82 | + - :val:`0x00` 83 | - :enum:`A` 84 | 85 | + - :val:`0x01` 86 | - :enum:`B` 87 | 88 | + - :val:`0x02` 89 | - :enum:`C` 90 | 91 | + - :val:`0x03` 92 | - :enum:`D` 93 | 94 | + - :val:`0x04` 95 | - :enum:`E` 96 | 97 | + - :val:`0x05` 98 | - :enum:`F` 99 | 100 | 101 | Hub Face 102 | ******** 103 | 104 | .. list-table:: 105 | :header-rows: 1 106 | :widths: 1 4 107 | :class: first-col-right 108 | 109 | + - Value (:term:`uint8 `) 110 | - Description 111 | 112 | + - :val:`0x00` 113 | - :enum:`Top` 114 | 115 | + - :val:`0x01` 116 | - :enum:`Front` 117 | 118 | + - :val:`0x02` 119 | - :enum:`Right` 120 | 121 | + - :val:`0x03` 122 | - :enum:`Bottom` 123 | 124 | + - :val:`0x04` 125 | - :enum:`Back` 126 | 127 | + - :val:`0x05` 128 | - :enum:`Left` 129 | 130 | 131 | Program Action 132 | ************** 133 | 134 | .. list-table:: 135 | :header-rows: 1 136 | :widths: 1 4 137 | :class: first-col-right 138 | 139 | + - Value (:term:`uint8 `) 140 | - Description 141 | 142 | + - :val:`0x00` 143 | - :enum:`Start` 144 | 145 | + - :val:`0x01` 146 | - :enum:`Stop` 147 | 148 | 149 | Response Status 150 | **************** 151 | 152 | .. list-table:: 153 | :header-rows: 1 154 | :widths: 1 4 155 | :class: first-col-right 156 | 157 | + - Value (:term:`uint8 `) 158 | - Description 159 | 160 | + - :val:`0x00` 161 | - :enum:`Acknowledged` 162 | 163 | + - :val:`0x01` 164 | - :enum:`Not Acknowledged` 165 | 166 | 167 | Motor End State 168 | *************** 169 | 170 | .. list-table:: 171 | :header-rows: 1 172 | :widths: 1 4 173 | :class: first-col-right 174 | 175 | + - Value (:term:`int8 `) 176 | - Description 177 | 178 | + - :val:`0x00` 179 | - :enum:`Coast` 180 | 181 | + - :val:`0x01` 182 | - :enum:`Brake` 183 | 184 | + - :val:`0x02` 185 | - :enum:`Hold` 186 | 187 | + - :val:`0x03` 188 | - :enum:`Continue` 189 | 190 | + - :val:`0x04` 191 | - :index:`\ ` 192 | Coast (:term:`smart `) 193 | 194 | + - :val:`0x05` 195 | - :index:`\ ` 196 | Brake (:term:`smart `) 197 | 198 | + - :val:`0xFF` 199 | - :enum:`Default` 200 | 201 | 202 | Motor Move Direction 203 | ******************** 204 | 205 | .. list-table:: 206 | :header-rows: 1 207 | :widths: 1 4 208 | :class: first-col-right 209 | 210 | + - Value (:term:`uint8 `) 211 | - Description 212 | 213 | + - :val:`0x00` 214 | - :enum:`Clockwise` 215 | 216 | + - :val:`0x01` 217 | - :enum:`Counter-Clockwise` 218 | 219 | + - :val:`0x02` 220 | - :enum:`Shortest Path` 221 | 222 | + - :val:`0x03` 223 | - :enum:`Longest Path` 224 | 225 | 226 | Motor Device Type 227 | ***************** 228 | 229 | .. list-table:: 230 | :header-rows: 1 231 | :widths: 1 4 232 | :class: first-col-right 233 | 234 | + - Value (:term:`uint8 `) 235 | - Description 236 | 237 | + - :val:`0x30` 238 | - :enum:`Medium Motor` 239 | 240 | + - :val:`0x31` 241 | - :enum:`Large Motor` 242 | 243 | + - :val:`0x41` 244 | - :enum:`Small Motor` 245 | -------------------------------------------------------------------------------- /docs/source/encoding.rst: -------------------------------------------------------------------------------- 1 | Encoding 2 | ######## 3 | 4 | Message encoding and framing can be broken into three steps: 5 | 6 | 1. Byte values ``0x00``, ``0x01``, and ``0x02`` are escaped using :term:`COBS`. 7 | 2. All bytes are XORed with ``0x03`` to ensure output contains no problematic control characters. 8 | 3. A delimiter is added to the end of the message. 9 | 10 | Deframing and decoding is the reverse of these steps. 11 | 12 | .. _COBS: 13 | 14 | Consistent Overhead Byte Stuffing (COBS) 15 | **************************************** 16 | 17 | **COBS** is an algorithm that can be used to escape certain values in a byte 18 | stream with a minimal overhead. 19 | This frees up the values to be used for special purposes, such as message 20 | delimiters or other control characters. 21 | 22 | The typical implementations of COBS only escape ``0x00``, but the SPIKE™ Prime 23 | implementation additionally escapes ``0x01`` and ``0x02``, as they are used for 24 | message delimiters. 25 | 26 | Delimeters are replaced with a special code word that indicates the number of 27 | bytes until the next delimiter and its value. 28 | The code word is calculated as follows: 29 | 30 | .. math:: 31 | 32 | \text{code_word} = \text{block_size} + 2 + \text{delimiter} \times 84 33 | 34 | The minimum value of :math:`\text{block_size}` is 1, as it will aways contain at 35 | least the code word itself. Therefore, by adding 2, the minimum value of 36 | :math:`\text{code_word}` is 3, ensuring no overlap with any delimiters. 37 | 38 | This leaves ``0xFF - 3`` bytes to be used for the block size, which must be 39 | divided by 3 (the number of different delimiters), resulting in a maximum block 40 | size of 84. 41 | 42 | For blocks with no delimiters, the code word :math:`255` is used. 43 | Thus, the code word can be decoded as follows: 44 | 45 | .. list-table:: 46 | :widths: 20 20 20 47 | :header-rows: 1 48 | 49 | + - Code word 50 | - Block size 51 | - Delimeter 52 | 53 | + - :math:`0 \leq n \leq 2` 54 | - N/A 55 | - N/A 56 | 57 | + - :math:`3 \leq n \leq 86` 58 | - :math:`n - 3` 59 | - :math:`0` 60 | 61 | + - :math:`87 \leq n \leq 170` 62 | - :math:`n - 87` 63 | - :math:`1` 64 | 65 | + - :math:`171 \leq n \leq 254` 66 | - :math:`n - 171` 67 | - :math:`2` 68 | 69 | + - :math:`255` 70 | - :math:`84` 71 | - N/A 72 | 73 | It's important that the encoded output always begins with a valid code word 74 | (i.e. the first byte is not a delimiter). 75 | This provides the decoder with a known starting point, allowing it to correctly 76 | decode the rest of the message. 77 | 78 | Below are sample implementations of the COBS encoding and decoding algorithms 79 | in Python: 80 | 81 | .. literalinclude:: /../../examples/python/cobs.py 82 | :name: COBS Encode 83 | :pyobject: encode 84 | :caption: COBS encoding algorithm 85 | 86 | .. literalinclude:: /../../examples/python/cobs.py 87 | :name: COBS Decode 88 | :pyobject: decode 89 | :caption: COBS decoding algorithm 90 | 91 | 92 | Escaping and framing 93 | ******************** 94 | 95 | After encoding the data with COBS as described :ref:`above `, the data 96 | will contain no bytes with a value of ``0x00``, ``0x01``, or ``0x02``. 97 | 98 | In SPIKE™ Prime, the values ``0x01`` and ``0x02`` are used as message delimiters. 99 | ``0x01`` signifies the start of a high-priority message, and ``0x02`` signifies 100 | the end of a message (and implicitly the start or resumption of a low-priority message). 101 | 102 | In addition to the delimiters ``0x01`` and ``0x02``, the value ``0x03`` must also 103 | be replaced, as it carries special meaning in the SPIKE™ Prime protocol. 104 | To ensure that the output contains no bytes with a value of ``0x03``, all bytes 105 | are bitwise XORed with ``0x03``. This effectively shifts the byte values by 3, 106 | turning ``0x03`` into ``0x00`` and vice versa, but because the COBS algorithm 107 | already removed any ``0x00`` bytes, the result will contain no ``0x03`` bytes. 108 | 109 | Finally, the message is framed by (optionally) prefixing it with ``0x01``, 110 | and (always) suffixing it with ``0x02``. See example below: 111 | 112 | .. literalinclude:: /../../examples/python/cobs.py 113 | :name: COBS Pack 114 | :pyobject: pack 115 | :caption: Encode, escape, frame 116 | 117 | 118 | Deframing and unescaping 119 | ************************ 120 | 121 | As bytes are received, they should be buffered into their respective priority 122 | queues until a complete message is received, as indicated by the presence of 123 | ``0x02``. 124 | 125 | The table below shows how to interpret each delimiter depending on the state 126 | of transmission: 127 | 128 | 129 | .. list-table:: 130 | :header-rows: 1 131 | :stub-columns: 1 132 | 133 | + - Delimiter 134 | - Message in progress 135 | - Interpretation 136 | - Action 137 | 138 | + - ``0x01`` 139 | - None 140 | - Start of high-priority message 141 | - Start buffering into high-priority queue 142 | 143 | + - ``0x01`` 144 | - High-priority 145 | - **Illegal state** 146 | - Sync error, clear queues and 147 | start buffering into high-priority queue 148 | 149 | + - ``0x01`` 150 | - Low-priority 151 | - Start of high-priority message 152 | - Pause buffering of low-priority message and 153 | start buffering into high-priority queue 154 | 155 | + - ``0x02`` 156 | - None 157 | - Start of low-priority message 158 | - Start buffering into low-priority queue 159 | 160 | + - ``0x02`` 161 | - High-priority 162 | - End of high-priority message 163 | - Process high-priority message, 164 | empty high-priority queue and 165 | start buffering into low-priority queue 166 | 167 | + - ``0x02`` 168 | - Low-priority 169 | - End of low-priority message 170 | - Process low-priority message and 171 | empty low-priority queue 172 | 173 | When a message is completed, it can be deframed by removing any leading ``0x01`` 174 | and trailing ``0x02`` bytes. The remaining bytes can then be unescaped by 175 | reversing the XOR operation and then COBS decoded as described :ref:`above `. 176 | 177 | Below is an example of how to deframe and unescape a message in Python: 178 | 179 | .. literalinclude:: /../../examples/python/cobs.py 180 | :name: COBS Unpack 181 | :pyobject: unpack 182 | :caption: Deframe, unescape, decode 183 | -------------------------------------------------------------------------------- /docs/source/messages.rst: -------------------------------------------------------------------------------- 1 | .. |response status| replace:: :ref:`Response Status`. 2 | 3 | .. index:: ! Messages 4 | 5 | Messages 6 | ######## 7 | 8 | All messages start with a :term:`uint8 ` indicating the message type, 9 | followed by zero or more fields specific to that message type. 10 | 11 | Unless otherwise specified, all message fields are little-endian, and strings 12 | are :term:`null-terminated `. 13 | 14 | .. rubric:: Message type quick reference 15 | 16 | .. message-quickref:: 17 | 18 | -------------------------------------------------------------------------------- 19 | 20 | .. _InfoRequest: 21 | 22 | .. message:: InfoRequest 23 | :id: 0 24 | 25 | .. _InfoResponse: 26 | 27 | .. message:: InfoResponse 28 | :id: 1 29 | 30 | :uint8: RPC major version. 31 | :uint8: RPC minor version. 32 | :uint16: RPC build number. 33 | :uint8: Firmware major version. 34 | :uint8: Firmware minor version. 35 | :uint16: Firmware build number. 36 | :uint16: Maximum packet size in bytes. 37 | :uint16: Maximum message size in bytes. 38 | :uint16: Maximum chunk size in bytes. 39 | :uint16: :ref:`Product Group Device` type. 40 | 41 | -------------------------------------------------------------------------------- 42 | 43 | .. message:: StartFirmwareUploadRequest 44 | :id: 10 45 | 46 | :uint8[20]: _`File SHA`. 47 | :uint32: :term:`CRC32` for the file. 48 | 49 | 50 | .. message:: StartFirmwareUploadResponse 51 | :id: 11 52 | 53 | :uint8: |response status| 54 | :uint32: Number of bytes already uploaded for this `File SHA`_. 55 | Used to resume an interrupted upload. 56 | 57 | -------------------------------------------------------------------------------- 58 | 59 | .. message:: StartFileUploadRequest 60 | :id: 12 61 | 62 | :string[32]: Name of the file as it will be stored on the hub. 63 | :uint8: :term:`Program slot` to store the file in. 64 | :uint32: :term:`CRC32` for the file. 65 | 66 | 67 | .. message:: StartFileUploadResponse 68 | :id: 13 69 | 70 | :uint8: |response status| 71 | 72 | -------------------------------------------------------------------------------- 73 | 74 | .. _TransferChunkRequest: 75 | 76 | .. message:: TransferChunkRequest 77 | :id: 16 78 | 79 | :uint32: Running :term:`CRC32` for the transfer. 80 | :uint16: Chunk payload `size`. 81 | :uint8[`size`]: Chunk payload. 82 | 83 | 84 | .. message:: TransferChunkResponse 85 | :id: 17 86 | 87 | :uint8: |response status| 88 | 89 | -------------------------------------------------------------------------------- 90 | 91 | .. message:: BeginFirmwareUpdateRequest 92 | :id: 20 93 | 94 | :uint8[20]: `File SHA`_. 95 | :uint32: :term:`CRC32` for the file. 96 | 97 | 98 | .. message:: BeginFirmwareUpdateResponse 99 | :id: 21 100 | 101 | :uint8: |response status| 102 | 103 | -------------------------------------------------------------------------------- 104 | 105 | .. message:: SetHubNameRequest 106 | :id: 22 107 | 108 | :string[30]: New hub name. 109 | 110 | 111 | .. message:: SetHubNameResponse 112 | :id: 23 113 | 114 | :uint8: |response status| 115 | 116 | -------------------------------------------------------------------------------- 117 | 118 | .. message:: GetHubNameRequest 119 | :id: 24 120 | 121 | 122 | .. message:: GetHubNameResponse 123 | :id: 25 124 | 125 | :string[30]: Hub name. 126 | 127 | -------------------------------------------------------------------------------- 128 | 129 | .. message:: DeviceUuidRequest 130 | :id: 26 131 | 132 | 133 | .. message:: DeviceUuidResponse 134 | :id: 27 135 | 136 | :uint8[16]: Device UUID. 137 | 138 | -------------------------------------------------------------------------------- 139 | 140 | .. message:: ProgramFlowRequest 141 | :id: 30 142 | 143 | :uint8: :ref:`Program action`. 144 | :uint8: :term:`Program slot` to use. 145 | 146 | 147 | .. message:: ProgramFlowResponse 148 | :id: 31 149 | 150 | :uint8: |response status| 151 | 152 | 153 | .. message:: ProgramFlowNotification 154 | :id: 32 155 | 156 | :uint8: :ref:`Program action`. 157 | 158 | -------------------------------------------------------------------------------- 159 | 160 | .. message:: ClearSlotRequest 161 | :id: 70 162 | 163 | :uint8: :term:`Program slot` to clear. 164 | 165 | 166 | .. message:: ClearSlotResponse 167 | :id: 71 168 | 169 | :uint8: |response status| 170 | 171 | -------------------------------------------------------------------------------- 172 | 173 | .. message:: ConsoleNotification 174 | :id: 33 175 | 176 | :string[256]: Console message. 177 | 178 | 179 | .. message:: TunnelMessage 180 | :id: 50 181 | 182 | :uint16: Payload `size` in bytes. 183 | :uint8[`size`]: Payload data. 184 | 185 | -------------------------------------------------------------------------------- 186 | 187 | .. message:: DeviceNotificationRequest 188 | :id: 40 189 | 190 | :uint16: Desired notification interval in milliseconds. (0 = disable) 191 | 192 | 193 | .. message:: DeviceNotificationResponse 194 | :id: 41 195 | 196 | :uint8: |response status| 197 | 198 | 199 | .. _DeviceNotification: 200 | 201 | .. message:: DeviceNotification 202 | :id: 60 203 | 204 | :uint16: Payload `size` in bytes. 205 | :uint8[`size`]: Payload as an array of **device messages** (see below). 206 | 207 | .. rubric:: Device messages 208 | :class: indent-remaining 209 | 210 | The DeviceNotification_ payload is a sequence of **device messages**. 211 | 212 | Like the standard messages, device messages start with a :term:`uint8 ` 213 | indicating how to interpret the rest of the message. 214 | 215 | 216 | .. message:: DeviceBattery 217 | :device-message: 218 | :id: 0 219 | 220 | :uint8: Battery level in percent. 221 | 222 | 223 | .. message:: DeviceImuValues 224 | :device-message: 225 | :id: 1 226 | 227 | :uint8: :ref:`Hub Face` pointing up. 228 | :uint8: :ref:`Hub Face` configured as **yaw face**. 229 | :int16: Yaw value in respect to the configured *yaw face*. 230 | :int16: Pitch value in respect to the configured *yaw face*. 231 | :int16: Roll value in respect to the configured *yaw face*. 232 | :int16: Accelerometer reading in X axis. 233 | :int16: Accelerometer reading in Y axis. 234 | :int16: Accelerometer reading in Z axis. 235 | :int16: Gyroscope reading in X axis. 236 | :int16: Gyroscope reading in Y axis. 237 | :int16: Gyroscope reading in Z axis. 238 | 239 | 240 | .. message:: Device5x5MatrixDisplay 241 | :device-message: 242 | :id: 2 243 | 244 | :uint8[25]: Pixel value for display. 245 | 246 | 247 | .. message:: DeviceMotor 248 | :device-message: 249 | :id: 10 250 | 251 | :uint8: :ref:`Hub Port` the motor is connected to. 252 | :uint8: :ref:`Motor device type`. 253 | :int16: Absolute position in degrees, in the range ``-180`` to ``179``. 254 | :int16: Power applied to the motor, in the range ``-10000`` to ``10000``. 255 | :int8: Speed of the motor, in the range ``-100`` to ``100``. 256 | :int32: Position of the motor, in the range ``-2147483648`` to ``2147483647``. 257 | 258 | 259 | .. message:: DeviceForceSensor 260 | :device-message: 261 | :id: 11 262 | 263 | :uint8: :ref:`Hub Port` the force sensor is connected to. 264 | :uint8: Measured value, in the range ``0`` to ``100``. 265 | :uint8: ``0x01`` if the sensor detects pressure, ``0x00`` otherwise. 266 | 267 | 268 | .. message:: DeviceColorSensor 269 | :device-message: 270 | :id: 12 271 | 272 | :uint8: :ref:`Hub Port` the color sensor is connected to. 273 | :int8: :ref:`Color` detected by the sensor. 274 | :uint16: Raw red value, in the range ``0`` to ``1023``. 275 | :uint16: Raw green value, in the range ``0`` to ``1023``. 276 | :uint16: Raw blue value, in the range ``0`` to ``1023``. 277 | 278 | 279 | .. message:: DeviceDistanceSensor 280 | :device-message: 281 | :id: 13 282 | 283 | :uint8: :ref:`Hub Port` the distance sensor is connected to. 284 | :int16: Measured distance in millimeters, in the range ``40`` to ``2000``. 285 | (``-1`` if no object is detected.) 286 | 287 | 288 | .. message:: Device3x3ColorMatrix 289 | :device-message: 290 | :id: 14 291 | 292 | :uint8: :ref:`Hub Port` the color matrix is connected to. 293 | :uint8[9]: Displayed pixel values. Each pixel is encoded with the brightness 294 | in the high nibble and the color in the low nibble. 295 | 296 | 297 | -------------------------------------------------------------------------------- /examples/python/messages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABC 3 | import struct 4 | 5 | 6 | class BaseMessage(ABC): 7 | @property 8 | def ID(cls) -> int: 9 | raise NotImplementedError 10 | 11 | def serialize(self) -> bytes: 12 | raise NotImplementedError 13 | 14 | @staticmethod 15 | def deserialize(data: bytes) -> BaseMessage: 16 | raise NotImplementedError 17 | 18 | def __str__(self) -> str: 19 | props = vars(self) 20 | plist = ", ".join(f"{k}={v}" for k, v in props.items()) 21 | return f"{self.__class__.__name__}({plist})" 22 | 23 | 24 | def StatusResponse(name: str, id: int): 25 | class BaseStatusResponse(BaseMessage): 26 | ID = id 27 | 28 | def __init__(self, success: bool): 29 | self.success = success 30 | 31 | @staticmethod 32 | def deserialize(data: bytes): 33 | id, status = struct.unpack(" InfoResponse: 76 | ( 77 | id, 78 | rpc_major, 79 | rpc_minor, 80 | rpc_build, 81 | firmware_major, 82 | firmware_minor, 83 | firmware_build, 84 | max_packet_size, 85 | max_message_size, 86 | max_chunk_size, 87 | product_group_device, 88 | ) = struct.unpack(" 31: 127 | raise ValueError( 128 | f"UTF-8 encoded file name too long: {len(encoded_name)} +1 >= 32" 129 | ) 130 | fmt = f" ProgramFlowNotification: 175 | id, stop = struct.unpack(" ConsoleNotification: 187 | text_bytes = data[1:].rstrip(b"\0") 188 | return ConsoleNotification(text_bytes.decode("utf8")) 189 | 190 | def __str__(self) -> str: 191 | return f"{self.__class__.__name__}({self.text!r})" 192 | 193 | 194 | class DeviceNotificationRequest(BaseMessage): 195 | ID = 0x28 196 | 197 | def __init__(self, interval_ms: int): 198 | self.interval_ms = interval_ms 199 | 200 | def serialize(self): 201 | return struct.pack(" DeviceNotification: 240 | id, size = struct.unpack(" str: 246 | updated = list(map(lambda x: x[0], self.messages)) 247 | return f"{self.__class__.__name__}({updated})" 248 | 249 | 250 | KNOWN_MESSAGES = { 251 | M.ID: M 252 | for M in ( 253 | InfoRequest, 254 | InfoResponse, 255 | ClearSlotRequest, 256 | ClearSlotResponse, 257 | StartFileUploadRequest, 258 | StartFileUploadResponse, 259 | TransferChunkRequest, 260 | TransferChunkResponse, 261 | ProgramFlowRequest, 262 | ProgramFlowResponse, 263 | ProgramFlowNotification, 264 | ConsoleNotification, 265 | DeviceNotificationRequest, 266 | DeviceNotificationResponse, 267 | DeviceNotification, 268 | ) 269 | } 270 | 271 | 272 | def deserialize(data: bytes): 273 | message_type = data[0] 274 | if message_type in KNOWN_MESSAGES: 275 | return KNOWN_MESSAGES[message_type].deserialize(data) 276 | raise ValueError(f"Unknown message: {data.hex(' ')}") 277 | -------------------------------------------------------------------------------- /docs/source/ext/directive_message.py: -------------------------------------------------------------------------------- 1 | import re 2 | from docutils import nodes 3 | from docutils.statemachine import ViewList, StringList 4 | from docutils.parsers.rst import directives 5 | from sphinx import addnodes 6 | from sphinx.util.docutils import SphinxDirective 7 | from sphinx.application import Sphinx 8 | from sphinx.util.nodes import ( 9 | make_refnode, 10 | nested_parse_with_titles, 11 | process_index_entry, 12 | ) 13 | 14 | 15 | class MessageData: 16 | def __init__(self, name, id, is_device_message): 17 | self.name = name 18 | self.id_dec = id 19 | self.id_hex = f"0x{id:0{2}X}" 20 | self.is_device_message = is_device_message 21 | 22 | 23 | class quickref(nodes.General, nodes.Element): 24 | pass 25 | 26 | 27 | class QuickRefDirective(SphinxDirective): 28 | def run(self): 29 | return [quickref("")] 30 | 31 | 32 | class MessageDirective(SphinxDirective): 33 | has_content = True 34 | required_arguments = 1 35 | optional_arguments = 0 36 | final_argument_whitespace = False 37 | option_spec = { 38 | "id": lambda x: int(x), 39 | "device-message": directives.flag, 40 | } 41 | 42 | message_data: MessageData = None 43 | 44 | @property 45 | def hex_id(self): 46 | return self.message_data.id_hex 47 | 48 | @property 49 | def message_name(self): 50 | return self.message_data.name 51 | 52 | @property 53 | def is_device_message(self): 54 | return self.message_data.is_device_message 55 | 56 | def _make_index(self, target_id): 57 | entries: list[str] = [] 58 | if self.is_device_message: 59 | entries.append(f"single: DeviceNotification; {self.message_name}") 60 | else: 61 | entries.append(f"single: {self.message_name}") 62 | entries.append(f"single: Messages; {self.message_name}") 63 | index = addnodes.index("", entries=[]) 64 | for entry in entries: 65 | index["entries"].extend(process_index_entry(entry, target_id)) 66 | return index 67 | 68 | def _render(self, lines: list[str]) -> list[nodes.Node]: 69 | vl = StringList() 70 | for line in lines: 71 | vl.append(line, "") 72 | parent = nodes.Element("") 73 | self.state.nested_parse(vl, 0, parent) 74 | return parent.children 75 | 76 | def _make_message_id_field(self) -> nodes.field: 77 | node = nodes.field("") 78 | node += nodes.field_name("", "uint8") 79 | node += nodes.field_body("") 80 | node[1].extend(self._render([f"Message type (``{self.hex_id}``)"])) 81 | return node 82 | 83 | def _parse_field_format(self, fmt: str): 84 | int_match = re.search(r"^u?int(\d+)$", fmt) 85 | if int_match is not None: 86 | return f":term:`{fmt} `" 87 | 88 | array_match = re.search(r"^u?int(\d+)\[([^]]*)\]$", fmt) 89 | if array_match is not None: 90 | return f":term:`{fmt} `" 91 | 92 | string_match = re.search(r"^string\[([^]]*)\]$", fmt) 93 | if string_match is not None: 94 | return f":term:`{fmt} `" 95 | 96 | return fmt 97 | 98 | def _make_table_from_fields(self, field_list: nodes.field_list) -> nodes.table: 99 | id_field = self._make_message_id_field() 100 | field_rows = [id_field] + field_list 101 | 102 | table = nodes.table("", classes=["colwidths-given", "first-col-right"]) 103 | columns = (("Format", 1), ("Description", 4)) 104 | tgroup = nodes.tgroup("", cols=len(columns)) 105 | tgroup.extend([nodes.colspec("", colwidth=c[1]) for c in columns]) 106 | table += tgroup 107 | thead = nodes.thead("") 108 | tgroup += thead 109 | tbody = nodes.tbody("") 110 | tgroup += tbody 111 | 112 | rhead = nodes.row("") 113 | rhead.extend( 114 | [nodes.entry("", nodes.paragraph("", c)) for c in ["Format", "Description"]] 115 | ) 116 | thead += rhead 117 | 118 | for field in field_rows: 119 | row = nodes.row("") 120 | fmt_display = self._parse_field_format(field[0].astext()) 121 | 122 | row += nodes.entry("", *self._render([fmt_display])) 123 | row += nodes.entry("", *field[1].children) 124 | 125 | tbody += row 126 | 127 | return table 128 | 129 | def run(self) -> list[nodes.Node]: 130 | self.message_data = MessageData( 131 | name=self.arguments[0], 132 | id=self.options["id"], 133 | is_device_message="device-message" in self.options, 134 | ) 135 | message_header = f"``{self.hex_id}`` {self.message_name}" 136 | 137 | content_node: nodes.Element = nodes.section() 138 | content_node.document = self.state.document 139 | 140 | content = ViewList(self.content, source="") 141 | content.insert( 142 | 0, 143 | StringList( 144 | [ 145 | message_header, 146 | "*" * len(message_header), 147 | "", 148 | ] 149 | ), 150 | ) 151 | nested_parse_with_titles(self.state, content, content_node, self.content_offset) 152 | section_node = content_node.next_node(nodes.section) 153 | if self.is_device_message: 154 | section_node["classes"].append("device-message") 155 | 156 | field_list = content_node[0].next_node(nodes.field_list) 157 | if field_list is None: 158 | field_list = nodes.field_list() 159 | content_node[0] += field_list 160 | table = self._make_table_from_fields(field_list) 161 | field_list.replace_self(table) 162 | 163 | ret: list[nodes.Node] = [] 164 | ret.append(self._make_index(section_node["ids"][0])) 165 | ret.extend(content_node.children) 166 | 167 | if not hasattr(self.env, "message_all_messages"): 168 | self.env.message_all_messages = [] 169 | 170 | self.env.message_all_messages.append( 171 | { 172 | "docname": self.env.docname, 173 | "lineno": self.lineno, 174 | "target": section_node, 175 | "data": self.message_data, 176 | } 177 | ) 178 | 179 | return ret 180 | 181 | 182 | def purge_messages(app, env, docname): 183 | if not hasattr(env, "message_all_messages"): 184 | return 185 | env.message_all_messages = [ 186 | message for message in env.message_all_messages if message["docname"] != docname 187 | ] 188 | 189 | 190 | def merge_messages(app, env, docnames, other): 191 | if not hasattr(env, "message_all_messages"): 192 | env.message_all_messages = [] 193 | if hasattr(other, "message_all_messages"): 194 | env.message_all_messages.extend(other.message_all_messages) 195 | 196 | 197 | def process_message_nodes(app, doctree, fromdocname): 198 | env = app.builder.env 199 | if not hasattr(env, "message_all_messages"): 200 | env.message_all_messages = [] 201 | 202 | for node in doctree.findall(quickref): 203 | # only quickref messages in the same document 204 | doc_messages = [ 205 | message 206 | for message in env.message_all_messages 207 | if message["docname"] == fromdocname 208 | ] 209 | if not doc_messages: 210 | node.replace_self([]) 211 | continue 212 | 213 | quickref_columns = ( 214 | ("Type ID\n(base 10)", 1), 215 | ("Type ID\n(base 16)", 1), 216 | ("Message", 6), 217 | ) 218 | table = nodes.table("", classes=["colwidths-given", "message-quickref"]) 219 | tgroup = nodes.tgroup("", cols=len(quickref_columns)) 220 | tgroup.extend([nodes.colspec("", colwidth=c[1]) for c in quickref_columns]) 221 | table += tgroup 222 | thead = nodes.thead("") 223 | tgroup += thead 224 | tbody = nodes.tbody("") 225 | tgroup += tbody 226 | 227 | rhead = nodes.row("") 228 | rhead.extend( 229 | [ 230 | nodes.entry("", *[nodes.paragraph("", l) for l in c[0].split("\n")]) 231 | for c in quickref_columns 232 | ] 233 | ) 234 | thead += rhead 235 | 236 | for message in doc_messages: 237 | row = nodes.row("") 238 | row += nodes.entry("", nodes.literal("", str(message["data"].id_dec))) 239 | row += nodes.entry("", nodes.literal("", message["data"].id_hex)) 240 | p = nodes.paragraph("") 241 | p += make_refnode( 242 | app.builder, 243 | fromdocname, 244 | message["docname"], 245 | message["target"]["ids"][0], 246 | nodes.Text(message["data"].name), 247 | message["data"].name, 248 | ) 249 | row += nodes.entry("", p) 250 | tbody += row 251 | 252 | node.replace_self(table) 253 | 254 | 255 | def setup(app: Sphinx): 256 | app.add_node(quickref) 257 | app.add_directive("message-quickref", QuickRefDirective) 258 | app.add_directive("message", MessageDirective) 259 | 260 | app.connect("env-purge-doc", purge_messages) 261 | app.connect("env-merge-info", merge_messages) 262 | app.connect("doctree-resolved", process_message_nodes) 263 | -------------------------------------------------------------------------------- /examples/python/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a simple example script showing how to 3 | 4 | * Connect to a SPIKE™ Prime hub over BLE 5 | * Subscribe to device notifications 6 | * Transfer and start a new program 7 | 8 | The script is heavily simplified and not suitable for production use. 9 | 10 | ---------------------------------------------------------------------- 11 | 12 | After prompting for confirmation to continue, the script will simply connect to 13 | the first device it finds advertising the SPIKE™ Prime service UUID, and proceed 14 | with the following steps: 15 | 16 | 1. Request information about the device (e.g. max chunk size for file transfers) 17 | 2. Subscribe to device notifications (e.g. state of IMU, display, sensors, motors, etc.) 18 | 3. Clear the program in a specific slot 19 | 4. Request transfer of a new program file to the slot 20 | 5. Transfer the program in chunks 21 | 6. Start the program 22 | 23 | If the script detects an unexpected response, it will print an error message and exit. 24 | Otherwise it will continue to run until the connection is lost or stopped by the user. 25 | (You can stop the script by pressing Ctrl+C in the terminal.) 26 | 27 | While the script is running, it will print information about the messages it sends and receives. 28 | """ 29 | 30 | import sys 31 | from typing import cast, TypeVar 32 | 33 | TMessage = TypeVar("TMessage", bound="BaseMessage") 34 | 35 | import cobs 36 | from messages import * 37 | from crc import crc 38 | 39 | import asyncio 40 | from bleak import BleakClient, BleakScanner 41 | from bleak.backends.characteristic import BleakGATTCharacteristic 42 | from bleak.backends.device import BLEDevice 43 | from bleak.backends.scanner import AdvertisementData 44 | 45 | 46 | SCAN_TIMEOUT = 10.0 47 | """How long to scan for devices before giving up (in seconds)""" 48 | 49 | SERVICE = "0000fd02-0000-1000-8000-00805f9b34fb" 50 | """The SPIKE™ Prime BLE service UUID""" 51 | 52 | RX_CHAR = "0000fd02-0001-1000-8000-00805f9b34fb" 53 | """The UUID the hub will receive data on""" 54 | 55 | TX_CHAR = "0000fd02-0002-1000-8000-00805f9b34fb" 56 | """The UUID the hub will transmit data on""" 57 | 58 | DEVICE_NOTIFICATION_INTERVAL_MS = 5000 59 | """The interval in milliseconds between device notifications""" 60 | 61 | EXAMPLE_SLOT = 0 62 | """The slot to upload the example program to""" 63 | 64 | EXAMPLE_PROGRAM = """import runloop 65 | from hub import light_matrix 66 | print("Console message from hub.") 67 | async def main(): 68 | await light_matrix.write("Hello, world!") 69 | runloop.run(main())""".encode( 70 | "utf8" 71 | ) 72 | """The utf8-encoded example program to upload to the hub""" 73 | 74 | answer = input( 75 | f"This example will override the program in slot {EXAMPLE_SLOT} of the first hub found. Do you want to continue? [Y/n] " 76 | ) 77 | if answer.strip().lower().startswith("n"): 78 | print("Aborted by user.") 79 | sys.exit(0) 80 | 81 | stop_event = asyncio.Event() 82 | 83 | async def main(): 84 | 85 | def match_service_uuid(device: BLEDevice, adv: AdvertisementData) -> bool: 86 | return SERVICE.lower() in adv.service_uuids 87 | 88 | print(f"\nScanning for {SCAN_TIMEOUT} seconds, please wait...") 89 | device = await BleakScanner.find_device_by_filter( 90 | filterfunc=match_service_uuid, timeout=SCAN_TIMEOUT 91 | ) 92 | 93 | if device is None: 94 | print( 95 | "No hubs detected. Ensure that a hub is within range, turned on, and awaiting connection." 96 | ) 97 | sys.exit(1) 98 | 99 | device = cast(BLEDevice, device) 100 | print(f"Hub detected! {device}") 101 | 102 | def on_disconnect(client: BleakClient) -> None: 103 | print("Connection lost.") 104 | stop_event.set() 105 | 106 | print("Connecting...") 107 | async with BleakClient(device, disconnected_callback=on_disconnect) as client: 108 | print("Connected!\n") 109 | 110 | service = client.services.get_service(SERVICE) 111 | rx_char = service.get_characteristic(RX_CHAR) 112 | tx_char = service.get_characteristic(TX_CHAR) 113 | 114 | # simple response tracking 115 | pending_response: tuple[int, asyncio.Future] = (-1, asyncio.Future()) 116 | 117 | # callback for when data is received from the hub 118 | def on_data(_: BleakGATTCharacteristic, data: bytearray) -> None: 119 | if data[-1] != 0x02: 120 | # packet is not a complete message 121 | # for simplicity, this example does not implement buffering 122 | # and is therefore unable to handle fragmented messages 123 | un_xor = bytes(map(lambda x: x ^ 3, data)) # un-XOR for debugging 124 | print(f"Received incomplete message:\n {un_xor}") 125 | return 126 | 127 | data = cobs.unpack(data) 128 | try: 129 | message = deserialize(data) 130 | print(f"Received: {message}") 131 | if message.ID == pending_response[0]: 132 | pending_response[1].set_result(message) 133 | if isinstance(message, DeviceNotification): 134 | # sort and print the messages in the notification 135 | updates = list(message.messages) 136 | updates.sort(key=lambda x: x[1]) 137 | lines = [f" - {x[0]:<10}: {x[1]}" for x in updates] 138 | print("\n".join(lines)) 139 | 140 | except ValueError as e: 141 | print(f"Error: {e}") 142 | 143 | # enable notifications on the hub's TX characteristic 144 | await client.start_notify(tx_char, on_data) 145 | 146 | # to be initialized 147 | info_response: InfoResponse = None 148 | 149 | # serialize and pack a message, then send it to the hub 150 | async def send_message(message: BaseMessage) -> None: 151 | print(f"Sending: {message}") 152 | payload = message.serialize() 153 | frame = cobs.pack(payload) 154 | 155 | # use the max_packet_size from the info response if available 156 | # otherwise, assume the frame is small enough to send in one packet 157 | packet_size = info_response.max_packet_size if info_response else len(frame) 158 | 159 | # send the frame in packets of packet_size 160 | for i in range(0, len(frame), packet_size): 161 | packet = frame[i : i + packet_size] 162 | await client.write_gatt_char(rx_char, packet, response=False) 163 | 164 | # send a message and wait for a response of a specific type 165 | async def send_request( 166 | message: BaseMessage, response_type: type[TMessage] 167 | ) -> TMessage: 168 | nonlocal pending_response 169 | pending_response = (response_type.ID, asyncio.Future()) 170 | await send_message(message) 171 | return await pending_response[1] 172 | 173 | # first message should always be an info request 174 | # as the response contains important information about the hub 175 | # and how to communicate with it 176 | info_response = await send_request(InfoRequest(), InfoResponse) 177 | 178 | # enable device notifications 179 | notification_response = await send_request( 180 | DeviceNotificationRequest(DEVICE_NOTIFICATION_INTERVAL_MS), 181 | DeviceNotificationResponse, 182 | ) 183 | if not notification_response.success: 184 | print("Error: failed to enable notifications") 185 | sys.exit(1) 186 | 187 | # clear the program in the example slot 188 | clear_response = await send_request( 189 | ClearSlotRequest(EXAMPLE_SLOT), ClearSlotResponse 190 | ) 191 | if not clear_response.success: 192 | print( 193 | "ClearSlotRequest was not acknowledged. This could mean the slot was already empty, proceeding..." 194 | ) 195 | 196 | # start a new file upload 197 | program_crc = crc(EXAMPLE_PROGRAM) 198 | start_upload_response = await send_request( 199 | StartFileUploadRequest("program.py", EXAMPLE_SLOT, program_crc), 200 | StartFileUploadResponse, 201 | ) 202 | if not start_upload_response.success: 203 | print("Error: start file upload was not acknowledged") 204 | sys.exit(1) 205 | 206 | # transfer the program in chunks 207 | running_crc = 0 208 | for i in range(0, len(EXAMPLE_PROGRAM), info_response.max_chunk_size): 209 | chunk = EXAMPLE_PROGRAM[i : i + info_response.max_chunk_size] 210 | running_crc = crc(chunk, running_crc) 211 | chunk_response = await send_request( 212 | TransferChunkRequest(running_crc, chunk), TransferChunkResponse 213 | ) 214 | if not chunk_response.success: 215 | print(f"Error: failed to transfer chunk {i}") 216 | sys.exit(1) 217 | 218 | # start the program 219 | start_program_response = await send_request( 220 | ProgramFlowRequest(stop=False, slot=EXAMPLE_SLOT), ProgramFlowResponse 221 | ) 222 | if not start_program_response.success: 223 | print("Error: failed to start program") 224 | sys.exit(1) 225 | 226 | # wait for the user to stop the script or disconnect the hub 227 | await stop_event.wait() 228 | 229 | 230 | if __name__ == "__main__": 231 | try: 232 | asyncio.run(main()) 233 | except KeyboardInterrupt: 234 | print("Interrupted by user.") 235 | stop_event.set() 236 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 The LEGO Group 2 | 3 | Licensed under the Apache License, Version 2.0 (the “License”) with the 4 | following modification to section 6. Trademarks: 5 | 6 | Section 6. Trademarks is deleted and replaced by the following wording: 7 | 8 | 6. Trademarks. This License does not grant permission to use the trademarks and 9 | trade names of the LEGO Group, including but not limited to the LEGO® logo and 10 | word mark, except (a) as required for reasonable and customary use in describing 11 | the origin of the Work, e.g. as described in section 4(c) of the License, and 12 | (b) to reproduce the content of the NOTICE file. Any reference to the Licensor 13 | must be made by making a reference to “the LEGO Group”, written in 14 | capitalized letters as in this example, unless the format in which the reference 15 | is made, requires lower case letters. 16 | 17 | You may not use this software except in compliance with the License and the 18 | modifications set out above. 19 | 20 | You may obtain a copy of the license at 21 | 22 | http://www.apache.org/licenses/LICENSE-2.0 23 | 24 | Unless required by applicable law or agreed to in writing, software distributed 25 | under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR 26 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 27 | specific language governing permissions and limitations under the License. 28 | 29 | ------------------------------------------------------------------------------- 30 | 31 | Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ 32 | 33 | 34 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 35 | 36 | 1. Definitions. 37 | 38 | "License" shall mean the terms and conditions for use, reproduction, and 39 | distribution as defined by Sections 1 through 9 of this document. 40 | 41 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 42 | owner that is granting the License. 43 | 44 | "Legal Entity" shall mean the union of the acting entity and all other entities 45 | that control, are controlled by, or are under common control with that entity. 46 | For the purposes of this definition, "control" means (i) the power, direct or 47 | indirect, to cause the direction or management of such entity, whether by 48 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 49 | outstanding shares, or (iii) beneficial ownership of such entity. 50 | 51 | "You" (or "Your") shall mean an individual or Legal Entity exercising 52 | permissions granted by this License. 53 | 54 | "Source" form shall mean the preferred form for making modifications, including 55 | but not limited to software source code, documentation source, and configuration 56 | files. 57 | 58 | "Object" form shall mean any form resulting from mechanical transformation or 59 | translation of a Source form, including but not limited to compiled object code, 60 | generated documentation, and conversions to other media types. 61 | 62 | "Work" shall mean the work of authorship, whether in Source or Object form, made 63 | available under the License, as indicated by a copyright notice that is included 64 | in or attached to the work (an example is provided in the Appendix below). 65 | 66 | "Derivative Works" shall mean any work, whether in Source or Object form, that 67 | is based on (or derived from) the Work and for which the editorial revisions, 68 | annotations, elaborations, or other modifications represent, as a whole, an 69 | original work of authorship. For the purposes of this License, Derivative Works 70 | shall not include works that remain separable from, or merely link (or bind by 71 | name) to the interfaces of, the Work and Derivative Works thereof. 72 | 73 | "Contribution" shall mean any work of authorship, including the original version 74 | of the Work and any modifications or additions to that Work or Derivative Works 75 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 76 | by the copyright owner or by an individual or Legal Entity authorized to submit 77 | on behalf of the copyright owner. For the purposes of this definition, 78 | "submitted" means any form of electronic, verbal, or written communication 79 | sent to the Licensor or its representatives, including but not limited to 80 | communication on electronic mailing lists, source code control systems, and 81 | issue tracking systems that are managed by, or on behalf of, the Licensor for 82 | the purpose of discussing and improving the Work, but excluding communication 83 | that is conspicuously marked or otherwise designated in writing by the copyright 84 | owner as "Not a Contribution." 85 | 86 | "Contributor" shall mean Licensor and any individual or Legal Entity on 87 | behalf of whom a Contribution has been received by Licensor and subsequently 88 | incorporated within the Work. 89 | 90 | 2. Grant of Copyright License. Subject to the terms and conditions of 91 | this License, each Contributor hereby grants to You a perpetual, worldwide, 92 | non-exclusive, no-charge, royalty-free, irrevocable copyright license to 93 | reproduce, prepare Derivative Works of, publicly display, publicly perform, 94 | sublicense, and distribute the Work and such Derivative Works in Source or 95 | Object form. 96 | 97 | 3. Grant of Patent License. Subject to the terms and conditions of this License, 98 | each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 99 | no-charge, royalty-free, irrevocable (except as stated in this section) patent 100 | license to make, have made, use, offer to sell, sell, import, and otherwise 101 | transfer the Work, where such license applies only to those patent claims 102 | licensable by such Contributor that are necessarily infringed by their 103 | Contribution(s) alone or by combination of their Contribution(s) with the 104 | Work to which such Contribution(s) was submitted. If You institute patent 105 | litigation against any entity (including a cross-claim or counterclaim in a 106 | lawsuit) alleging that the Work or a Contribution incorporated within the Work 107 | constitutes direct or contributory patent infringement, then any patent licenses 108 | granted to You under this License for that Work shall terminate as of the date 109 | such litigation is filed. 110 | 111 | 4. Redistribution. You may reproduce and distribute copies of the Work or 112 | Derivative Works thereof in any medium, with or without modifications, and in 113 | Source or Object form, provided that You meet the following conditions: 114 | 115 | (a) You must give any other recipients of the Work or Derivative Works a copy of 116 | this License; and 117 | 118 | (b) You must cause any modified files to carry prominent notices stating that 119 | You changed the files; and 120 | 121 | (c) You must retain, in the Source form of any Derivative Works that You 122 | distribute, all copyright, patent, trademark, and attribution notices from the 123 | Source form of the Work, excluding those notices that do not pertain to any part 124 | of the Derivative Works; and 125 | 126 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then 127 | any Derivative Works that You distribute must include a readable copy of the 128 | attribution notices contained within such NOTICE file, excluding those notices 129 | that do not pertain to any part of the Derivative Works, in at least one of the 130 | following places: within a NOTICE text file distributed as part of the 131 | Derivative Works; within the Source form or documentation, if provided along 132 | with the Derivative Works; or, within a display generated by the Derivative 133 | Works, if and wherever such third-party notices normally appear. The contents of 134 | the NOTICE file are for informational purposes only and do not modify the 135 | License. You may add Your own attribution notices within Derivative Works that 136 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 137 | provided that such additional attribution notices cannot be construed as 138 | modifying the License. 139 | 140 | You may add Your own copyright statement to Your modifications and may provide 141 | additional or different license terms and conditions for use, reproduction, or 142 | distribution of Your modifications, or for any such Derivative Works as a whole, 143 | provided Your use, reproduction, and distribution of the Work otherwise complies 144 | with the conditions stated in this License. 145 | 146 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 147 | Contribution intentionally submitted for inclusion in the Work by You to the 148 | Licensor shall be under the terms and conditions of this License, without any 149 | additional terms or conditions. Notwithstanding the above, nothing herein shall 150 | supersede or modify the terms of any separate license agreement you may have 151 | executed with Licensor regarding such Contributions. 152 | 153 | 6. Trademarks. This License does not grant permission to use the trade names, 154 | trademarks, service marks, or product names of the Licensor, except as required 155 | for reasonable and customary use in describing the origin of the Work and 156 | reproducing the content of the NOTICE file. 157 | 158 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to 159 | in writing, Licensor provides the Work (and each Contributor provides its 160 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 161 | ANY KIND, either express or implied, including, without limitation, any 162 | warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS 163 | FOR A PARTICULAR PURPOSE. You are solely responsible for determining the 164 | appropriateness of using or redistributing the Work and assume any risks 165 | associated with Your exercise of permissions under this License. 166 | 167 | 8. Limitation of Liability. In no event and under no legal theory, whether 168 | in tort (including negligence), contract, or otherwise, unless required by 169 | applicable law (such as deliberate and grossly negligent acts) or agreed to in 170 | writing, shall any Contributor be liable to You for damages, including any 171 | direct, indirect, special, incidental, or consequential damages of any character 172 | arising as a result of this License or out of the use or inability to use the 173 | Work (including but not limited to damages for loss of goodwill, work stoppage, 174 | computer failure or malfunction, or any and all other commercial damages or 175 | losses), even if such Contributor has been advised of the possibility of such 176 | damages. 177 | 178 | 9. Accepting Warranty or Additional Liability. While redistributing the 179 | Work or Derivative Works thereof, You may choose to offer, and charge a 180 | fee for, acceptance of support, warranty, indemnity, or other liability 181 | obligations and/or rights consistent with this License. However, in accepting 182 | such obligations, You may act only on Your own behalf and on Your sole 183 | responsibility, not on behalf of any other Contributor, and only if You agree to 184 | indemnify, defend, and hold each Contributor harmless for any liability incurred 185 | by, or claims asserted against, such Contributor by reason of your accepting any 186 | such warranty or additional liability. 187 | 188 | END OF TERMS AND CONDITIONS 189 | --------------------------------------------------------------------------------