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