├── .markdownlintrc ├── mypy.ini ├── scripts ├── mqtt2pipe ├── sample-rate ├── simulate ├── webserver ├── serial-term ├── py-repl ├── py-upload ├── flash ├── ble2mqtt.ts └── read-serial-samples ├── docs └── img │ ├── ble.png │ ├── ble-relay.png │ ├── 2consumers.png │ ├── 2providers.png │ ├── cloud-mqtt.png │ ├── local-mqtt.png │ └── serial2mqtt.png ├── imu_tools ├── __init__.py ├── control.py ├── config.py ├── pub.py └── sub.py ├── npm-package ├── .gitignore ├── example │ └── index.html ├── package.json ├── README.md └── package-lock.json ├── .gitignore ├── setup.cfg ├── .pylintrc ├── pyboard ├── ufunctools.py ├── .pylintrc ├── config.py.tmpl ├── boot.py ├── bno055_fake.py ├── webserver.py ├── sensors.py ├── ble.py ├── jsonb.py ├── bno055.py └── main.py ├── web ├── .prettierrc ├── styles.css ├── d3-graph.html ├── barchart.html ├── highcharts-chart.html ├── 3d-model.html ├── js │ ├── highcharts-chart.js │ ├── imu-connection.js │ ├── utils.js │ ├── sensor-encoding.js │ ├── d3-graph.js │ ├── barchart.js │ ├── dashboard.js │ ├── 3d-model.js │ ├── ble-client.js │ └── mqtt-client.js └── index.html ├── test └── test-bno055.py ├── pyproject.toml ├── package.json ├── blender └── motion.py ├── README.md └── poetry.lock /.markdownlintrc: -------------------------------------------------------------------------------- 1 | no-alt-text: false 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /scripts/mqtt2pipe: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | poetry run pub --pipe 4 | -------------------------------------------------------------------------------- /scripts/sample-rate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | poetry run pub --sample-rate 4 | -------------------------------------------------------------------------------- /scripts/simulate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | poetry run pub --continuous "$@" 4 | -------------------------------------------------------------------------------- /docs/img/ble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osteele/imu-tools/HEAD/docs/img/ble.png -------------------------------------------------------------------------------- /imu_tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .sub import main as sub 2 | from .pub import main as pub 3 | -------------------------------------------------------------------------------- /docs/img/ble-relay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osteele/imu-tools/HEAD/docs/img/ble-relay.png -------------------------------------------------------------------------------- /docs/img/2consumers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osteele/imu-tools/HEAD/docs/img/2consumers.png -------------------------------------------------------------------------------- /docs/img/2providers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osteele/imu-tools/HEAD/docs/img/2providers.png -------------------------------------------------------------------------------- /docs/img/cloud-mqtt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osteele/imu-tools/HEAD/docs/img/cloud-mqtt.png -------------------------------------------------------------------------------- /docs/img/local-mqtt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osteele/imu-tools/HEAD/docs/img/local-mqtt.png -------------------------------------------------------------------------------- /docs/img/serial2mqtt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osteele/imu-tools/HEAD/docs/img/serial2mqtt.png -------------------------------------------------------------------------------- /scripts/webserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -eu 2 | 3 | poetry run python -m http.server -b 127.0.0.1 -d web 4 | -------------------------------------------------------------------------------- /npm-package/.gitignore: -------------------------------------------------------------------------------- 1 | ble-client.js 2 | index.js 3 | mqtt-client.js 4 | sensor-encoding.js 5 | utils.js 6 | node_modules/ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .pytest_cache 3 | pyboard/config.py 4 | esp32-*.bin 5 | node_modules/ 6 | imu_tools.egg-info/ 7 | *.pyc 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | known_first_party = config 3 | known_third_party = esp, machine, micropython, network, sensors, serial, utime, umqtt, ustruct, click, paho, loguru 4 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable= 3 | bad-continuation, 4 | invalid-name, 5 | missing-class-docstring, 6 | missing-docstring, 7 | missing-function-docstring, 8 | missing-module-docstring, 9 | -------------------------------------------------------------------------------- /pyboard/ufunctools.py: -------------------------------------------------------------------------------- 1 | def partial(func, *args, **kwargs): 2 | def wrapper(*more_args, **more_kwargs): 3 | kw = kwargs.copy() 4 | kw.update(more_kwargs) 5 | return func(*(args + more_args), **kw) 6 | 7 | return wrapper 8 | -------------------------------------------------------------------------------- /scripts/serial-term: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -eu 2 | 3 | SERIAL_PORT=${RSHELL_PORT:-/dev/tty.SLAB_USBtoUART} 4 | if [ ! -e "$SERIAL_PORT" ]; then 5 | echo "No device on port ${SERIAL_PORT}" 1>&2 6 | exit 1 7 | fi 8 | screen "$SERIAL_PORT" "${RSHELL_BAUD:-115200}" 9 | -------------------------------------------------------------------------------- /scripts/py-repl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -eu 2 | 3 | SERIAL_PORT=${RSHELL_PORT:-/dev/tty.SLAB_USBtoUART} 4 | if [ ! -e "$SERIAL_PORT" ]; then 5 | echo "No device on port ${SERIAL_PORT}" 1>&2 6 | exit 1 7 | fi 8 | 9 | poetry run rshell -p "$SERIAL_PORT" repl 10 | -------------------------------------------------------------------------------- /pyboard/.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | init-hook='import os,sys; sys.path.insert(0,os.getenv("PYTHONPATH")) 3 | 4 | disable= 5 | global-statement, 6 | missing-class-docstring, 7 | missing-docstring, 8 | missing-function-docstring, 9 | missing-module-docstring, 10 | invalid-name 11 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "overrides": [ 7 | { 8 | "files": ["d3-graph.js", "highcharts-chart.js"], 9 | "options": { 10 | "semi": false 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /test/test-bno055.py: -------------------------------------------------------------------------------- 1 | import bno055 2 | from machine import I2C, Pin 3 | 4 | scl, sda = (Pin(22), Pin(23)) if sys.platform == "esp32" else (Pin(5), Pin(4)) 5 | i2c = I2C(scl=scl, sda=sda, timeout=1000) # HUZZAH8266 6 | s = bno055.BNO055(i2c) 7 | s.operation_mode(bno055.NDOF_MODE) 8 | 9 | # s.operation_mode() 10 | s.temperature() 11 | s.accelerometer() 12 | s.magnetometer() 13 | s.gyroscope() 14 | s.euler() 15 | -------------------------------------------------------------------------------- /scripts/py-upload: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -eu 2 | 3 | SERIAL_PORT=${RSHELL_PORT:-/dev/tty.SLAB_USBtoUART} 4 | if [ ! -e "$SERIAL_PORT" ]; then 5 | echo "No device on port ${SERIAL_PORT}" 1>&2 6 | exit 1 7 | fi 8 | 9 | if [ "$#" -eq 0 ]; then 10 | poetry run rshell -p "$SERIAL_PORT" rsync pyboard /pyboard 11 | poetry run rshell -p "$SERIAL_PORT" repl \~ 'machine.reset()' \~ 12 | else 13 | poetry run rshell -p "$SERIAL_PORT" cp "${@}" /pyboard 14 | fi 15 | -------------------------------------------------------------------------------- /pyboard/config.py.tmpl: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | # MCU configuration 3 | 4 | MQTT_CONFIG = { 5 | "host": "m10.cloudmqtt.com", 6 | "port": ..., 7 | "user": "...", 8 | "password": "...", 9 | } 10 | 11 | # { ssid_name: password | None } 12 | WIFI_NETWORKS = {"home": "...", "Oliver's iPhone": "..."} 13 | 14 | SEND_MQTT_SENSOR_DATA = False 15 | 16 | # Send data on the serial port 17 | SEND_SERIAL_SENSOR_DATA = True 18 | 19 | # Run an HTTP server 20 | RUN_HTTP_SERVER = False 21 | 22 | TRACE_SPI = False 23 | 24 | # Use a dummy IMU 25 | USE_DUMMY_IMU = True 26 | -------------------------------------------------------------------------------- /scripts/flash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -eu 2 | 3 | SERIAL_PORT=${RSHELL_PORT:-/dev/tty.SLAB_USBtoUART} 4 | if [ ! -e "$SERIAL_PORT" ]; then 5 | echo "No device on port ${SERIAL_PORT}" 1>&2 6 | exit 1 7 | fi 8 | 9 | erase_flash=0 10 | if [ "${1:-}" == --erase ]; then 11 | erase_flash=1 12 | shift 13 | elif [ "$#" -ne 1 ]; then 14 | echo "Usage: $0 [--erase] IMAGE" 1>&2 15 | exit 1 16 | fi 17 | 18 | [ $erase_flash -eq 1 ] && poetry run esptool.py --chip esp32 --port "$SERIAL_PORT" erase_flash 19 | poetry run esptool.py --chip esp32 --port "$SERIAL_PORT" write_flash -z 0x1000 "$1" 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "imu-tools" 3 | version = "0.1.0" 4 | description = "Command-line tools for using an IMU connected to MQTT" 5 | authors = ["Oliver Steele "] 6 | license = "MIT" 7 | 8 | [tool.poetry.scripts] 9 | pub = "imu_tools:pub" 10 | sub = "imu_tools:sub" 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.7" 14 | click = "^7.0" 15 | loguru = "^0.4.0" 16 | paho-mqtt = "^1.5" 17 | pyserial = "^3.4" 18 | rshell = "^0.0.26" 19 | esptool = "^2.8" 20 | 21 | [tool.poetry.dev-dependencies] 22 | isort = "^4.3" 23 | pylint-common = "^0.2.5" 24 | 25 | [build-system] 26 | requires = ["poetry>=0.12"] 27 | build-backend = "poetry.masonry.api" 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imu-tools", 3 | "version": "1.0.0", 4 | "description": "Command-line scripts for BLE IMU connection.", 5 | "main": "scripts/ble2mqtt.ts", 6 | "private": true, 7 | "dependencies": { 8 | "mqtt": "^4.1.0", 9 | "ts-node": "^8.10.2", 10 | "typescript": "^3.9.5", 11 | "webbluetooth": "^2.1.0" 12 | }, 13 | "devDependencies": {}, 14 | "scripts": { 15 | "ble2mqtt": "ts-node scripts/ble2mqtt.ts" 16 | }, 17 | "repository": "git:osteele/imu-tools.git", 18 | "author": "Oliver Steele ", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/osteele/imu-tools/issues" 22 | }, 23 | "homepage": "https://github.com/osteele/imu-tools#readme" 24 | } 25 | -------------------------------------------------------------------------------- /web/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | margin: 0; 4 | } 5 | 6 | #connection-gui { 7 | position: absolute; 8 | right: 0; 9 | bottom: 20px; 10 | } 11 | #bt-connection-button { 12 | position: absolute; 13 | z-index: 1; 14 | } 15 | 16 | body.connected #bt-connection-button { 17 | display: none; 18 | } 19 | 20 | /* Adapted from http://bl.ocks.org/simenbrekken/6634070 */ 21 | .graph .axis { 22 | stroke-width: 1; 23 | } 24 | 25 | .graph .axis .tick line { 26 | stroke: black; 27 | } 28 | 29 | .graph .axis .tick text { 30 | fill: black; 31 | font-size: 0.7em; 32 | } 33 | 34 | .graph .axis .domain { 35 | fill: none; 36 | stroke: black; 37 | } 38 | 39 | .graph .group { 40 | fill: none; 41 | stroke: black; 42 | stroke-width: 1.5; 43 | } 44 | -------------------------------------------------------------------------------- /npm-package/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | p5.js imu-tools example 8 | 9 | 10 | 11 | 12 | 13 |
Waiting for connection…
14 |
No sensor values have been received.
16 | 17 | 18 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /web/d3-graph.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | IMU D3 Graph 8 | 9 | 10 | 11 | 13 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /npm-package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imu-tools", 3 | "author": "Oliver Steele (https://osteele.com/)", 4 | "version": "0.1.4", 5 | "description": "Web browser subscription to BLE and MQTT IMU sensor data", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "unpkg": "index.js", 9 | "scripts": { 10 | "example": "http-server example", 11 | "prepare": "cp ../web/js/{ble-client,imu-connection,mqtt-client,sensor-encoding,utils}.js . && mv imu-connection.js index.js", 12 | "test": "echo \"Warning: no test specified\"" 13 | }, 14 | "files": [ 15 | "/ble-client.js", 16 | "/index.js", 17 | "/mqtt-client.js", 18 | "/sensor-encoding.js", 19 | "/utils.js" 20 | ], 21 | "repository": "github:osteele/imu-tools", 22 | "keywords": [ 23 | "mqtt", 24 | "imu", 25 | "bno055" 26 | ], 27 | "homepage": "https://github.com/osteele/imu-tools/npm-package", 28 | "devDependencies": { 29 | "http-server": "^0.12.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/barchart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | IMU Bar Chart 8 | 9 | 10 | 11 | 13 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /imu_tools/control.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | 4 | import click 5 | import paho.mqtt.client as mqtt 6 | from loguru import logger 7 | 8 | from .config import mqtt_options 9 | 10 | logger.remove() 11 | logger.add(sys.stdout, format="{time:mm:ss.SS}: {message}", level="INFO") 12 | 13 | 14 | def on_publish(_client, userdata, mid): 15 | logger.info("Published(id={}) to {}", mid, userdata) 16 | 17 | 18 | @click.command() 19 | @mqtt_options 20 | @click.argument("message", nargs=1, required=False) 21 | def main(*, user, host, port, password, message): 22 | client = mqtt.Client(userdata=host) 23 | client.on_publish = on_publish 24 | client.on_log = True 25 | 26 | if user: 27 | client.username_pw_set(user, password=password) 28 | client.connect(host, port) 29 | 30 | device_id = "*" 31 | topic = f"imu/control/{device_id}" 32 | info = client.publish(topic, payload=message or "ping") 33 | info.wait_for_publish() 34 | 35 | 36 | if __name__ == "__main__": 37 | main() # pylint: disable=missing-kwoa 38 | -------------------------------------------------------------------------------- /web/highcharts-chart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | IMU Highcharts Chart 8 | 9 | 11 | 13 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /web/3d-model.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | IMU 3D Model 8 | 9 | 10 | 11 | 13 | 15 | 17 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /imu_tools/config.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | 4 | import click 5 | 6 | 7 | def to_int(value): 8 | return int(value) if isinstance(value, str) else value 9 | 10 | 11 | MQTT_DEFAULTS = { 12 | "host": os.environ.get("MQTT_HOST", "localhost"), 13 | "port": to_int(os.environ.get("MQTT_PORT", 1883)), 14 | "user": os.environ.get("MQTT_USER", None), 15 | "password": os.environ.get("MQTT_PASSWORD", None), 16 | } 17 | MQTT_DEFAULTS["port"] = to_int(MQTT_DEFAULTS["port"]) 18 | 19 | 20 | def mqtt_options(func): 21 | @click.option( 22 | "-h", 23 | "--host", 24 | metavar="HOSTNAME", 25 | help="MQTT host name", 26 | default=MQTT_DEFAULTS["host"], 27 | ) 28 | @click.option( 29 | "-u", 30 | "--user", 31 | metavar="USERNAME", 32 | help="MQTT user name", 33 | default=MQTT_DEFAULTS["user"], 34 | ) 35 | @click.option( 36 | "-p", 37 | "--port", 38 | metavar="PORT_NUMBER", 39 | type=int, 40 | help="MQTT port", 41 | default=MQTT_DEFAULTS["port"], 42 | ) 43 | @click.option("--password", help="MQTT password", default=MQTT_DEFAULTS["password"]) 44 | @functools.wraps(func) 45 | def wrapper(*args, **kwargs): 46 | return func(*args, **kwargs) 47 | 48 | return wrapper 49 | -------------------------------------------------------------------------------- /web/js/highcharts-chart.js: -------------------------------------------------------------------------------- 1 | import { onSensorData } from './imu-connection.js' 2 | import { throttled } from './utils.js' 3 | 4 | Highcharts.setOptions({ global: { useUTC: false } }) 5 | 6 | const chart = new Highcharts.Chart({ 7 | chart: { renderTo: 'container' }, 8 | title: { text: 'IMU Data' }, 9 | xAxis: { 10 | type: 'datetime', 11 | tickPixelInterval: 100, 12 | }, 13 | series: [], 14 | }) 15 | 16 | const seriesIndices = {} 17 | 18 | function addSampleScalar(name, value, timestamp) { 19 | let seriesIndex = seriesIndices[name] 20 | if (seriesIndex === undefined) { 21 | seriesIndex = Object.keys(seriesIndices).length 22 | seriesIndices[name] = seriesIndex 23 | chart.addSeries({ 24 | id: seriesIndex, 25 | name: name, 26 | data: [], 27 | }) 28 | } 29 | const series = chart.series[0] 30 | chart.series[seriesIndex].addPoint( 31 | [timestamp, value], 32 | true, 33 | series.data.length > 500 34 | ) 35 | } 36 | 37 | onSensorData( 38 | throttled(({ deviceId, data }) => { 39 | // const [a0, a1, a2] = data.accelerometer 40 | const [e0, e1, e2] = data.euler 41 | const values = { e0, e1, e2 } 42 | const timestamp = new Date().getTime() 43 | Object.keys(values).forEach((k) => { 44 | addSampleScalar(k, values[k], timestamp) 45 | }) 46 | }) 47 | ) 48 | -------------------------------------------------------------------------------- /pyboard/boot.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import os 3 | 4 | import esp 5 | import machine 6 | import network 7 | import utime 8 | 9 | from config import WIFI_NETWORKS as ssid_passwords 10 | 11 | esp.osdebug(None) 12 | gc.collect() 13 | 14 | print("\nDevice type =", os.uname().sysname) 15 | print("HMAC =", ":".join(map("{:02x}".format, machine.unique_id()))) 16 | 17 | 18 | def wifi_connect(): 19 | station = network.WLAN(network.STA_IF) 20 | station.active(True) 21 | if station.isconnected(): 22 | print('Connected to WiFi network "' + station.config("essid") + '"') 23 | else: 24 | ssids = [service[0].decode() for service in station.scan()] 25 | known_ssids = [ssid for ssid in ssids if ssid in ssid_passwords] 26 | 27 | if known_ssids: 28 | ssid = known_ssids[0] 29 | password = ssid_passwords[ssid] 30 | station.connect(ssid, password) 31 | print('Connecting to WiFi network "' + ssid + '"', end="...") 32 | while not station.isconnected(): 33 | utime.sleep_ms(250) 34 | print(end=".") 35 | 36 | ip_address, _subnet_mask, _gateway, _dns_server = station.ifconfig() 37 | print("success.\nIP address =", ip_address) 38 | elif ssids: 39 | ssid_names = list(dict([(s, 1) for s in ssids]).keys()) 40 | ssid_names.sort(key=lambda s: s.lower()) 41 | print("No known WiFi network in", ", ".join(ssid_names)) 42 | else: 43 | print("No WiFi networks found") 44 | 45 | 46 | wifi_connect() 47 | -------------------------------------------------------------------------------- /pyboard/bno055_fake.py: -------------------------------------------------------------------------------- 1 | class BNO055: 2 | __counter = 1 3 | 4 | def operation_mode(self, *args): 5 | pass 6 | 7 | def temperature(self): 8 | frac = self.__counter / 1000 9 | self.__counter += 1 10 | self.__counter %= 1000 11 | return 27 + frac 12 | 13 | def accelerometer(self): 14 | frac = self.__counter / 1000 15 | self.__counter += 1 16 | self.__counter %= 1000 17 | return (20 + frac, 21 + frac, 22 + frac) 18 | 19 | def euler(self): 20 | frac = self.__counter / 1000 21 | self.__counter += 1 22 | self.__counter %= 1000 23 | return (30 + frac, 31 + frac, 32 + frac) 24 | 25 | def gravity(self): 26 | frac = self.__counter / 1000 27 | self.__counter += 1 28 | self.__counter %= 1000 29 | return (40 + frac, 41 + frac, 42 + frac) 30 | 31 | def gyroscope(self): 32 | frac = self.__counter / 1000 33 | self.__counter += 1 34 | self.__counter %= 1000 35 | return (50 + frac, 51 + frac, 52 + frac) 36 | 37 | def linear_acceleration(self): 38 | frac = self.__counter / 1000 39 | self.__counter += 1 40 | self.__counter %= 1000 41 | return (60 + frac, 61 + frac, 62 + frac) 42 | 43 | def magnetometer(self): 44 | frac = self.__counter / 1000 45 | self.__counter += 1 46 | self.__counter %= 1000 47 | return (70 + frac, 71 + frac, 72 + frac) 48 | 49 | def quaternion(self): 50 | frac = self.__counter / 1000 51 | self.__counter += 1 52 | self.__counter %= 1000 53 | return (80 + frac, 81 + frac, 82 + frac) 54 | -------------------------------------------------------------------------------- /web/js/imu-connection.js: -------------------------------------------------------------------------------- 1 | import * as bleClient from './ble-client.js'; 2 | import * as mqttClient from './mqtt-client.js'; 3 | export { bleAvailable, connect as bleConnect } from './ble-client.js'; 4 | export { openConnection as mqttConnect } from './mqtt-client.js'; 5 | export * from './mqtt-client.js'; 6 | import { imuQuatToEuler } from './utils.js'; 7 | 8 | const devices = {}; 9 | 10 | /** 11 | * Register a callback that is applied to each sensor message. 12 | * 13 | * @param {*} fn 14 | */ 15 | export function onSensorData(fn) { 16 | let errored = false; 17 | function wrapper(device) { 18 | // If no Euler angle is present, reconstruct it from the quaternion 19 | let data = device.data; 20 | if (data.quaternion && !data.euler) { 21 | data = { 22 | ...data, 23 | euler: imuQuatToEuler(data.quaternion), 24 | }; 25 | device = { ...device, data }; 26 | } 27 | 28 | devices[device.deviceId] = device; 29 | 30 | // Ignore the callback after the first error, to avoid repeating the 31 | // error message on each message. 32 | if (errored) return; 33 | 34 | // Stop calling the function if throws an exception. Use `try...finally` 35 | // instead of `try...catch` because the latter destroys the stacktrace, 36 | // as of Chrome 80.0.3987.163. 37 | // https://bugs.chromium.org/p/chromium/issues/detail?id=60240 38 | let failed = true; 39 | try { 40 | fn(device, devices); 41 | failed = false; 42 | } finally { 43 | if (failed) { 44 | errored = true; 45 | } 46 | } 47 | } 48 | 49 | // subscribe to both BLE and MQTT connections. 50 | bleClient.onSensorData(wrapper); 51 | mqttClient.onSensorData(wrapper); 52 | } 53 | -------------------------------------------------------------------------------- /blender/motion.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | from pathlib import Path 5 | 6 | import bpy 7 | import mathutils 8 | 9 | BONE = "forearm.R" 10 | FLOAT_RE = re.compile(r"\d+(?:\.\d*(?:e[-+]\d+)?)?") 11 | PIPE_PATH = "/tmp/imu-relay.pipe" 12 | 13 | fp = None 14 | active = False 15 | 16 | print("objects =", bpy.data.objects.keys()) 17 | ob = bpy.data.objects["free3dmodel_skeleton"] 18 | print("bones =", ob.pose.bones.keys()) 19 | 20 | 21 | def update_angle(): 22 | if not active or not fp: 23 | return 0.1 24 | line = None 25 | while True: 26 | line2 = fp.readline() 27 | if not line2: 28 | break 29 | line = line2 30 | if line and line.startswith("quaternion:"): 31 | q_angle = [float(s) for s in re.findall(FLOAT_RE, line)] 32 | if len(q_angle) == 4: 33 | ob.pose.bones[BONE].rotation_quaternion = mathutils.Vector(q_angle) 34 | else: 35 | print("Skipping:", line) 36 | return 0.05 37 | 38 | 39 | bpy.app.timers.register(update_angle) 40 | 41 | 42 | class ModalOperator(bpy.types.Operator): 43 | bl_idname = "object.modal_operator" 44 | bl_label = "MoCap Modal Operator" 45 | 46 | def __init__(self): 47 | print("modal operator start") 48 | 49 | def __del__(self): 50 | print("modal operator stop") 51 | 52 | def execute(self, _context): 53 | return {"FINISHED"} 54 | 55 | def modal(self, _context, event): 56 | global active, fp 57 | if event.type in {"LEFTMOUSE", "ESC"}: 58 | active = False 59 | fp.close() 60 | fp = None 61 | return {"FINISHED"} 62 | if event.type in {"RIGHTMOUSE", "ESC"}: 63 | active = False 64 | return {"CANCELLED"} 65 | return {"RUNNING_MODAL"} 66 | 67 | def invoke(self, _context, _event): 68 | global active, fp 69 | active = True 70 | if not Path(PIPE_PATH).exists(): 71 | subprocess.run(["mkfifo", PIPE_PATH], check=True) 72 | fp = open( 73 | PIPE_PATH, "r", opener=lambda p, _f: os.open(p, os.O_RDONLY | os.O_NONBLOCK) 74 | ) 75 | return {"RUNNING_MODAL"} 76 | 77 | 78 | bpy.utils.register_class(ModalOperator) 79 | bpy.ops.object.modal_operator("INVOKE_DEFAULT") 80 | -------------------------------------------------------------------------------- /web/js/utils.js: -------------------------------------------------------------------------------- 1 | export const isMobile = Boolean( 2 | /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( 3 | navigator.userAgent 4 | ) 5 | ); 6 | 7 | export function eulerToQuat(yaw, pitch, roll) { 8 | const [c1, s1] = [Math.cos(yaw / 2), Math.sin(yaw / 2)]; 9 | const [c2, s2] = [Math.cos(pitch / 2), Math.sin(pitch / 2)]; 10 | const [c3, s3] = [Math.cos(roll / 2), Math.sin(roll / 2)]; 11 | const w = c1 * c2 * c3 - s1 * s2 * s3; 12 | const x = s1 * s2 * c3 + c1 * c2 * s3; 13 | const y = s1 * c2 * c3 + c1 * s2 * s3; 14 | const z = c1 * s2 * c3 - s1 * c2 * s3; 15 | return [x, y, z, w]; 16 | } 17 | 18 | export function quatToEuler(q0, q1, q2, q3) { 19 | const rx = Math.atan2(2 * (q0 * q1 + q2 * q3), 1 - 2 * (q1 * q1 + q2 * q2)); 20 | const ry = Math.asin(2 * (q0 * q2 - q3 * q1)); 21 | const rz = Math.atan2(2 * (q0 * q3 + q1 * q2), 1 - 2 * (q2 * q2 + q3 * q3)); 22 | return [rx, ry, rz]; 23 | } 24 | 25 | export function imuQuatToEuler([q0, q1, q2, q3]) { 26 | const radians = quatToEuler(q3, q1, q2, q0); 27 | const degrees = radians.map((e) => (e * 180) / Math.PI); 28 | if (degrees[2] < 0) degrees[2] += 360; 29 | return degrees; 30 | } 31 | 32 | /** Convert a quaternion to a 4 x 4 transformation matrix. 33 | */ 34 | export function quatToMatrix(w, x, y, z) { 35 | const x2 = x ** 2; 36 | const y2 = y ** 2; 37 | const z2 = z ** 2; 38 | const wx = w * x; 39 | const wy = w * y; 40 | const wz = w * z; 41 | const xy = x * y; 42 | const xz = x * z; 43 | const yz = y * z; 44 | return [ 45 | ...[1 - 2 * (y2 + z2), 2 * (xy - wz), 2 * (xz + wy), 0], 46 | ...[2 * (xy + wz), 1 - 2 * (x2 + z2), 2 * (yz - wx), 0], 47 | ...[2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (x2 + y2), 0], 48 | ...[0, 0, 0, 1], 49 | ]; 50 | } 51 | 52 | /** Apply callback no more than once per animation frame. 53 | * 54 | * @return A function that wraps callback, and queues it to be called on the 55 | * next animation frame. 56 | */ 57 | export function throttled(callback) { 58 | const buffer = []; 59 | return (data) => { 60 | if (buffer.length === 0) { 61 | requestAnimationFrame(() => { 62 | callback(buffer.pop()); 63 | }); 64 | } 65 | buffer[0] = data; 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /npm-package/README.md: -------------------------------------------------------------------------------- 1 | # IMU BLE / MQTT Subscription 2 | 3 | This package allows code in a web page to subscribe to data that is published by 4 | an ESP32 connected to a BNO055 IMU. The ESP32 should be running either the 5 | MicroPython code in [imu-tools](https://github.com/osteele/imu-tools), or the 6 | Arduino (C++) code in 7 | [Arduino-BLE-IMU](https://github.com/osteele/Arduino-BLE-IMU). See those 8 | projects for information on how to configure the ESP32. 9 | 10 | Additional examples are in 11 | [osteele/imu-client-examples](https://github.com/osteele/imu-client-examples). 12 | 13 | ## Usage – MQTT 14 | 15 | To use this code with an MQTT broker, include the MQTT library in the header 16 | (between the `` and the `` tags) of the HTML file: 17 | 18 | ```html 19 | 20 | ``` 21 | 22 | Add the following to `sketch.js` to import the `onSensorData` function, and use 23 | it to subscribe to sensor data: 24 | 25 | ```js 26 | import { 27 | mqttConnect, 28 | onSensorData, 29 | } from "https://cdn.jsdelivr.net/npm/imu-tools/index.js"; 30 | 31 | mqttConnect({ hostname: "example.com" }); 32 | onSensorData((data) => console.info("sensor data:", data)); 33 | ``` 34 | 35 | The `hostname` option to `mqttConnect` can also specify a port number: 36 | `"example.com:1877"`. The options may also include `username`, `password`, and 37 | `deviceid`. If `deviceId` is specified, only messages from the specified device 38 | are processed. 39 | 40 | ### Connection Settings Control Panel 41 | 42 | The MQTT broker can be set by enabling a control panel that allows the user to 43 | specify the MQTT connection settings. 44 | 45 | Add the following to the HTML header: 46 | 47 | ```html 48 | 49 | ``` 50 | 51 | The page will now display a control panel in the upper right corner. 52 | 53 | The location of the control panel can be customized by adding an HTMl element 54 | with id `connection-gui`. 55 | 56 | The controller saves the connection settings to local storage. They are used by 57 | all pages that include this library. 58 | 59 | ## Acknowledgements 60 | 61 | This code uses the [Eclipse Paho JavaScript 62 | Client](https://www.eclipse.org/paho/clients/js/) for MQTT connectivity. It uses 63 | [dat.gui](https://github.com/dataarts/dat.gui) to display the control panel. 64 | 65 | ## License 66 | 67 | MIT 68 | -------------------------------------------------------------------------------- /pyboard/webserver.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | HTTP_SOCKET = None 4 | 5 | # pylint: disable=line-too-long 6 | def create_web_page_content(mqtt_client, sensor_data): 7 | html = """ 8 | ESP BO055 IMU 9 | 10 |

ESP BO055 IMU

""" 14 | if mqtt_client: 15 | html += "

Connected to mqtt://" + mqtt_client.server + "

" 16 | if sensor_data: 17 | for k, value in sensor_data.items(): 18 | html += "

" + k + ": " + str(value) + "

" 19 | html += """ 20 |

21 |

""" 22 | return html 23 | 24 | 25 | def start_http_server(wifi_station): 26 | global HTTP_SOCKET 27 | 28 | HTTP_SOCKET = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 29 | try: 30 | HTTP_SOCKET.bind(("", 80)) 31 | except OSError as err: 32 | if err.args[0] == 112: 33 | print(err) 34 | return 35 | raise err 36 | HTTP_SOCKET.listen(5) 37 | ip_address, _subnet_mask, _gateway, _dns_server = wifi_station.ifconfig() 38 | print("Listening on http://" + ip_address) 39 | 40 | 41 | def service_http_request(mqtt_client, sensor_data): 42 | global HTTP_SOCKET 43 | try: 44 | 45 | conn, addr = HTTP_SOCKET.accept() 46 | connected = True 47 | except OSError: # EAGAIN 48 | connected = False 49 | if connected: 50 | print("Received HTTP request from", addr[0]) 51 | # request = conn.recv(1024) 52 | # print("Content =", request) 53 | response = create_web_page_content(mqtt_client, sensor_data) 54 | conn.send(b"HTTP/1.1 200 OK\n") 55 | conn.send(b"Content-Type: text/html\n") 56 | conn.send(b"Connection: close\n\n") 57 | conn.sendall(response) 58 | conn.close() 59 | -------------------------------------------------------------------------------- /pyboard/sensors.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utime as time 4 | from machine import I2C, Pin 5 | 6 | import bno055 7 | import bno055_fake 8 | import config 9 | 10 | I2C_PINS = (22, 23) if sys.platform == "esp32" else (5, 4) 11 | 12 | # The latest caught error, exposed for CLI debugging 13 | LATEST_ERROR = None 14 | 15 | 16 | def get_imu(use_dummy=False): 17 | scl, sda = I2C_PINS 18 | i2c = I2C(scl=Pin(scl), sda=Pin(sda), freq=100000, timeout=1000) 19 | devices = i2c.scan() 20 | print("I2C scan ->", devices) 21 | if 40 not in devices: 22 | if devices: 23 | print("I2C(scl={}, sda={}) devices:".format(scl, sda), devices) 24 | missing_imu_msg = "No IMU @ I2C(scl={}, sda={})".format(scl, sda) 25 | if not use_dummy: 26 | raise Exception(missing_imu_msg) 27 | print(missing_imu_msg + ". Using dummy data.") 28 | return bno055_fake.BNO055() 29 | for i in range(10, 0, -1): 30 | try: 31 | bno = bno055.BNO055(i2c, verbose=config.TRACE_SPI) 32 | print("Using BNO055 @ I2C(scl={}, sda={})".format(scl, sda)) 33 | bno.operation_mode(bno055.NDOF_MODE) 34 | return bno 35 | except OSError as err: 36 | if i == 1 or not is_retriable_error(err): 37 | raise err 38 | print( 39 | "Error finding BNO055: {:s}; retrying".format(str(err)), file=sys.stderr 40 | ) 41 | time.sleep_ms(1000) 42 | 43 | 44 | def is_retriable_error(err): 45 | global LATEST_ERROR 46 | LATEST_ERROR = err 47 | ENODEV = 19 48 | ETIMEDOUT = 110 49 | return err.args[0] in (ENODEV, ETIMEDOUT) 50 | 51 | 52 | def get_sensor_data(imu): 53 | try: 54 | data = { 55 | "timestamp": time.ticks_ms(), 56 | "accelerometer": imu.accelerometer(), 57 | "calibration": imu.calibration(), 58 | "euler": imu.euler(), 59 | "gyroscope": imu.gyroscope(), 60 | "linear_acceleration": imu.linear_acceleration(), 61 | "magnetometer": imu.magnetometer(), 62 | "quaternion": imu.quaternion(), 63 | "temperature": imu.temperature(), 64 | } 65 | if data["temperature"] == 0.0: 66 | imu.operation_mode(bno055.NDOF_MODE) 67 | except OSError as err: 68 | if is_retriable_error(err): 69 | print("Error", err, file=sys.stderr) 70 | return None 71 | raise err 72 | # if hasattr(imu, "bmp280"): 73 | # data["pressure"] = imu.bmp280.pressure 74 | return data 75 | -------------------------------------------------------------------------------- /web/js/sensor-encoding.js: -------------------------------------------------------------------------------- 1 | import { quatToMatrix } from './utils.js'; 2 | 3 | const BLE_IMU_ACCEL_FLAG = 0x01; 4 | const BLE_IMU_MAG_FLAG = 0x02; 5 | const BLE_IMU_GYRO_FLAG = 0x04; 6 | const BLE_IMU_CALIBRATION_FLAG = 0x08; 7 | const BLE_IMU_EULER_FLAG = 0x10; 8 | const BLE_IMU_QUATERNION_FLAG = 0x20; 9 | const BLE_IMU_LINEAR_ACCEL_FLAG = 0x40; 10 | 11 | let reportedMessageVersionError = false; 12 | 13 | export function decodeSensorData(dataView) { 14 | let data = {}; 15 | 16 | let i = 0; 17 | const nextUint8 = () => dataView.getUint8(i++); 18 | const nextUint16 = () => dataView.getUint16((i += 2) - 2); 19 | const nextFloat32 = () => decodeFloat32(dataView, (i += 4) - 4); 20 | const nextFloat64 = () => decodeFloat64(dataView, (i += 8) - 8); 21 | const nextFloat32Array = (length) => 22 | Array.from({ length }).map(nextFloat32); 23 | const nextFloat64Array = (length) => 24 | Array.from({ length }).map(nextFloat64); 25 | 26 | const messageVersion = nextUint8(); 27 | if (messageVersion !== 1) { 28 | if (!reportedMessageVersionError) { 29 | reportedMessageVersionError = true; 30 | alert( 31 | 'Upgrade to a newer version of imu-tools to read data from this device.' 32 | ); 33 | } 34 | return null; 35 | } 36 | 37 | const flags = nextUint8(); 38 | nextUint16(); // timestamp 39 | 40 | if (flags & BLE_IMU_QUATERNION_FLAG) { 41 | const quat = nextFloat32Array(4); 42 | const [q0, q1, q2, q3] = quat; 43 | const orientationMatrix = quatToMatrix(q3, q1, q0, q2); 44 | data = { orientationMatrix, quaternion: quat, ...data }; 45 | } 46 | 47 | if (flags & BLE_IMU_ACCEL_FLAG) { 48 | const acceleration = nextFloat32Array(3); 49 | data = { acceleration, ...data }; 50 | } 51 | if (flags & BLE_IMU_GYRO_FLAG) { 52 | const gyroscope = nextFloat32Array(3); 53 | data = { gyroscope, ...data }; 54 | } 55 | if (flags & BLE_IMU_MAG_FLAG) { 56 | const magnetometer = nextFloat32Array(3); 57 | data = { magnetometer, ...data }; 58 | } 59 | if (flags & BLE_IMU_LINEAR_ACCEL_FLAG) { 60 | const linearAcceleration = nextFloat32Array(3); 61 | data = { linearAcceleration, ...data }; 62 | } 63 | 64 | return data; 65 | 66 | function decodeFloat32(value, n) { 67 | const ar = new Uint8Array(4); 68 | for (let i = 0; i < 4; ++i) { 69 | ar[i] = value.getUint8(n + 3 - i); 70 | } 71 | return new DataView(ar.buffer).getFloat32(0); 72 | } 73 | 74 | function decodeFloat64(value, n) { 75 | const ar = new Uint8Array(8); 76 | for (let i = 0; i < 8; ++i) { 77 | ar[i] = value.getUint8(n + 7 - i); 78 | } 79 | return new DataView(ar.buffer).getFloat64(0); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pyboard/ble.py: -------------------------------------------------------------------------------- 1 | from micropython import const 2 | import bluetooth 3 | 4 | _IRQ_CENTRAL_CONNECT = const(1 << 0) 5 | _IRQ_CENTRAL_DISCONNECT = const(1 << 1) 6 | _IRQ_GATTS_WRITE = const(1 << 2) 7 | _IRQ_GATTS_READ_REQUEST = const(1 << 3) 8 | _IRQ_SCAN_RESULT = const(1 << 4) 9 | _IRQ_SCAN_COMPLETE = const(1 << 5) 10 | _IRQ_PERIPHERAL_CONNECT = const(1 << 6) 11 | _IRQ_PERIPHERAL_DISCONNECT = const(1 << 7) 12 | _IRQ_GATTC_SERVICE_RESULT = const(1 << 8) 13 | _IRQ_GATTC_CHARACTERISTIC_RESULT = const(1 << 9) 14 | _IRQ_GATTC_DESCRIPTOR_RESULT = const(1 << 10) 15 | _IRQ_GATTC_READ_RESULT = const(1 << 11) 16 | _IRQ_GATTC_WRITE_STATUS = const(1 << 12) 17 | _IRQ_GATTC_NOTIFY = const(1 << 13) 18 | _IRQ_GATTC_INDICATE = const(1 << 14) 19 | 20 | connections = set() 21 | 22 | 23 | def bt_irq_handler(event, data): 24 | if event == _IRQ_CENTRAL_CONNECT: 25 | conn_handle, _addr_type, _addr = data 26 | print("BT connect", conn_handle) 27 | connections.add(conn_handle) 28 | elif event == _IRQ_CENTRAL_DISCONNECT: 29 | conn_handle, _addr_type, _addr = data 30 | print("BT disconnect", conn_handle) 31 | connections.remove(conn_handle) 32 | elif event == _IRQ_GATTS_WRITE: 33 | conn_handle, attr_handle = data 34 | msg = bt.gatts_read(attr_handle) 35 | print("BT Rx({}, {})".format(conn_handle, attr_handle), msg) 36 | if attr_handle == rx and msg == b"ping\n": 37 | transmit("pong\n") 38 | elif event == _IRQ_GATTC_READ_RESULT: 39 | conn_handle, _value_handle, _char_data = data 40 | print("BT Rx", conn_handle) 41 | elif event == _IRQ_GATTC_WRITE_STATUS: 42 | conn_handle, _value_handle, _status = data 43 | print("BT Tx1", conn_handle) 44 | 45 | 46 | def transmit(data): 47 | for conn in connections: 48 | bt.gatts_notify(conn, tx, data) 49 | 50 | 51 | bt = bluetooth.BLE() 52 | 53 | print("Activating BLE...") 54 | bt.active(True) 55 | 56 | HR_SERVICE_UUID = bluetooth.UUID(0x180D) 57 | UART_SERVICE_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") 58 | UART_TX_CHAR_UUID = bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E") 59 | UART_RX_CHAR_UUID = bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E") 60 | 61 | HR_CHAR = (bluetooth.UUID(0x2A37), bluetooth.FLAG_READ | bluetooth.FLAG_NOTIFY) 62 | HR_SERVICE = (HR_SERVICE_UUID, (HR_CHAR,)) 63 | UART_TX_CHAR = (UART_TX_CHAR_UUID, bluetooth.FLAG_READ | bluetooth.FLAG_NOTIFY) 64 | UART_RX_CHAR = (UART_RX_CHAR_UUID, bluetooth.FLAG_WRITE) 65 | UART_SERVICE = (UART_SERVICE_UUID, (UART_TX_CHAR, UART_RX_CHAR)) 66 | SERVICES = (HR_SERVICE, UART_SERVICE) 67 | ((hr,), (tx, rx)) = bt.gatts_register_services(SERVICES) 68 | 69 | bt.irq(bt_irq_handler) 70 | 71 | 72 | def advertise(): 73 | bt.gap_advertise( 74 | 100, 75 | b"\x02\x01\x1a\x03\x03\x10\x10\x0b\tNYUSHIMA-P\x0b\xffL\x00\x10\x06\x13\x1a:\xe1u\x0c", 76 | ) 77 | 78 | 79 | advertise() 80 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | IMU Web Client Index 8 | 9 | 11 | 29 | 30 | 31 | 32 | 33 | 34 | 36 | 38 | 39 | 40 | 41 | 42 |
43 |
44 |

Web Interfaces

45 |
46 |
3D Model
47 |
A 3D model that tracks the orientation of the IMU.
48 |
Bar Chart
49 |
Bar charts of all the sensor values.
50 |
D3 Graph
51 |
Uses D3 to graph sensor values over time.
52 |
Highcharts Chart
53 |
Uses Highcharts to chart sensor values over time.
54 |
55 |
56 | 57 |
58 |

Online Devices

59 |
60 |
61 | 62 |
63 |
64 |
65 |
66 | 67 | 68 | 70 | 72 | 74 | 76 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /web/js/d3-graph.js: -------------------------------------------------------------------------------- 1 | import { onSensorData } from './imu-connection.js' 2 | import { throttled } from './utils.js' 3 | 4 | // D3 5 | // 6 | // Adapted from http://bl.ocks.org/simenbrekken/6634070 7 | 8 | const limit = 60 * 1 9 | const duration = 750 10 | let now = new Date(Date.now() - duration) 11 | 12 | const width = 800 13 | const height = 300 14 | 15 | const groups = {} 16 | 17 | const x = d3.time 18 | .scale() 19 | .domain([now - (limit - 2), now - duration]) 20 | .range([0, width]) 21 | 22 | const y = d3.scale.linear().domain([0, 100]).range([height, 0]) 23 | 24 | const line = d3.svg 25 | .line() 26 | .interpolate('basis') 27 | .x((_d, i) => x(now - (limit - 1 - i) * duration)) 28 | .y(y) 29 | 30 | const svg = d3 31 | .select('.graph') 32 | .append('svg') 33 | .attr('class', 'chart') 34 | .attr('width', width) 35 | .attr('height', height + 50) 36 | 37 | const axis = svg 38 | .append('g') 39 | .attr('class', 'x axis') 40 | .attr('transform', 'translate(0,' + height + ')') 41 | .call((x.axis = d3.svg.axis().scale(x).orient('bottom'))) 42 | 43 | const paths = svg.append('g') 44 | 45 | function findGroup(name) { 46 | let group = groups[name] 47 | if (!group) { 48 | const colors = [ 49 | 'orange', 50 | 'green', 51 | 'gray', 52 | 'black', 53 | 'red', 54 | 'purple', 55 | 'blue', 56 | ] 57 | group = { 58 | value: 0, 59 | color: colors[Object.keys(groups).length % colors.length], 60 | data: d3.range(limit).map(function () { 61 | return 0 62 | }), 63 | } 64 | groups[name] = group 65 | group.path = paths 66 | .append('path') 67 | .data([group.data]) 68 | .attr('class', name + ' group') 69 | .style('stroke', group.color) 70 | } 71 | return group 72 | } 73 | 74 | function addSample(sample) { 75 | const device_id = sample.device_id 76 | delete sample.device_id 77 | now = new Date() 78 | 79 | for (var name in sample) { 80 | if (!sample.hasOwnProperty(name)) { 81 | continue 82 | } 83 | const group = findGroup(device_id + ':' + name) 84 | group.data.push(sample[name] / 10) 85 | group.path.attr('d', line) 86 | } 87 | 88 | x.domain([now - (limit - 2) * duration, now - duration]) 89 | 90 | axis.transition().duration(duration).ease('linear').call(x.axis) 91 | 92 | paths 93 | .attr('transform', null) 94 | .transition() 95 | .duration(duration) 96 | .ease('linear') 97 | .attr('transform', 'translate(' + x(now - (limit - 1) * duration) + ')') 98 | 99 | for (let name in sample) { 100 | const group = findGroup(device_id + ':' + name) 101 | group.data.shift() 102 | } 103 | } 104 | 105 | onSensorData( 106 | throttled(({ deviceId, data }) => { 107 | const [ax, ay, az] = data.accelerometer 108 | const [gx, gy, gz] = data.gyroscope 109 | const [mx, my, mz] = data.magnetometer 110 | const [e0, e1, e2] = data.euler 111 | // const [q0, q1, q2, q3] = sensors.quaternion 112 | // addSample({ device_id, e0, e1, e2 }) 113 | addSample({ deviceId, ax, ay, az }) 114 | addSample({ deviceId, mx, my, mz }) 115 | addSample({ deviceId, gx, gy, gz }) 116 | // addSample({ device_id, q0, q1, q2, q3 }) 117 | }) 118 | ) 119 | -------------------------------------------------------------------------------- /web/js/barchart.js: -------------------------------------------------------------------------------- 1 | import { onSensorData } from './imu-connection.js'; 2 | 3 | const IGNORED_PROPERTIES = ['calibration', 'orientationMatrix', 'receivedAt']; 4 | 5 | const BAR_WIDTH = 25; 6 | const SUBGRAPH_HEIGHT = 300; 7 | 8 | const PALETTE = ['red', 'green', 'blue', 'gray', 'orange', 'pink']; 9 | 10 | let sensorData = {}; 11 | let ranges = {}; // sensor name => [min, max] observed range 12 | let freeze = false; 13 | let step = false; 14 | 15 | export function setup() { 16 | createCanvas(windowWidth, windowHeight); 17 | 18 | onSensorData(({ data }) => { 19 | if (!freeze || step) sensorData = { ...data }; 20 | step = false; 21 | }); 22 | const button = createButton('Freeze'); 23 | button.position(100, 0); 24 | button.mousePressed(() => { 25 | freeze = !freeze; 26 | if (freeze) { 27 | button.elt.innerText = 'Resume'; 28 | stepButton.show(); 29 | } else { 30 | button.elt.innerText = 'Freeze'; 31 | stepButton.hide(); 32 | } 33 | }); 34 | const stepButton = createButton('Sample'); 35 | stepButton.position(170, 0); 36 | stepButton.mousePressed(() => { 37 | step = true; 38 | }); 39 | stepButton.hide(); 40 | } 41 | 42 | export function draw() { 43 | background(200, 200, 212); 44 | clear(); 45 | noStroke(); 46 | 47 | let subgraphX = 10; 48 | let subgraphY = 10; 49 | const keys = Object.keys(sensorData) 50 | .filter((name) => !IGNORED_PROPERTIES.includes(name)) 51 | .sort(); 52 | keys.forEach((key, i) => { 53 | let values = sensorData[key]; 54 | if (!Array.isArray(values)) { 55 | values = [values]; 56 | } 57 | 58 | const subgraphWidth = values.length * (BAR_WIDTH + 2); 59 | if (subgraphX + subgraphWidth > width) { 60 | subgraphX = 10; 61 | subgraphY += SUBGRAPH_HEIGHT + 85; 62 | } 63 | push(); 64 | translate(subgraphX, subgraphY); 65 | 66 | // update the range 67 | barChart(key, values, PALETTE[i % PALETTE.length]); 68 | 69 | pop(); 70 | subgraphX += subgraphWidth + 50; 71 | }); 72 | } 73 | 74 | function barChart(key, values, barColor) { 75 | const label = capitalize(key); 76 | 77 | // update the running max and min from the new values 78 | let [min, max] = ranges[key] || [0, 0]; 79 | min = Math.min.apply(null, values.concat([min])); 80 | max = Math.max.apply(null, values.concat([max])); 81 | ranges[key] = [min, max]; 82 | 83 | fill('gray'); 84 | textSize(9); 85 | text(`${formatPrecision(min)}…${formatPrecision(max)}`, 0, 25); 86 | 87 | fill(barColor); 88 | textSize(14); 89 | text(label, 0, SUBGRAPH_HEIGHT + 80); 90 | textSize(9); 91 | values.forEach((v, i) => { 92 | const x = i * (BAR_WIDTH + 2); 93 | const yMid = SUBGRAPH_HEIGHT / 2 + 25; 94 | const height = (v * SUBGRAPH_HEIGHT) / 2 / Math.max(-min, max); 95 | rect(x, yMid - 0.5, BAR_WIDTH, 1); 96 | rect(x, yMid, BAR_WIDTH, height); 97 | push(); 98 | translate(x, SUBGRAPH_HEIGHT + 40); 99 | angleMode(DEGREES); 100 | rotate(60); 101 | text(formatPrecision(v), 0, 0); 102 | pop(); 103 | }); 104 | } 105 | 106 | /** Capitalize the first letter, e.g. "euler" => "Euler" */ 107 | function capitalize(str) { 108 | return str && str[0].toUpperCase() + str.slice(1); 109 | } 110 | 111 | /** Format to two decimals, e.g. 123.345 => "123.45" */ 112 | function formatPrecision(n) { 113 | return String(n).replace(/(\.\d\d)\d+/, '$1'); 114 | } 115 | -------------------------------------------------------------------------------- /pyboard/jsonb.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | _NULL_VALUE = 0 4 | _FALSE_VALUE = 1 5 | _TRUE_VALUE = 2 6 | _INT_TYPE = 3 7 | _FLOAT_TYPE = 4 8 | _STRING_TYPE = 5 9 | _LIST_TYPE = 6 10 | _MAP_TYPE = 7 11 | 12 | _CONSTS = {None: _NULL_VALUE, False: _FALSE_VALUE, True: _TRUE_VALUE} 13 | 14 | schema_fmt = None 15 | 16 | 17 | def dumps(value): 18 | global schema_fmt 19 | if not schema_fmt: 20 | schema_fmt = "!" + "".join(_iter_encodings(value)) 21 | return struct.pack(schema_fmt, *_iter_values(value)) 22 | 23 | 24 | def _iter_encodings(value): 25 | if isinstance(value, str): 26 | yield "is" 27 | elif isinstance(value, (list, tuple)): 28 | yield from (i for v in value for i in _iter_encodings(v)) 29 | elif isinstance(value, dict): 30 | yield from (i for v in value.values() for i in _iter_encodings(v)) 31 | else: 32 | yield "f" 33 | 34 | 35 | def _iter_values(value): 36 | if isinstance(value, str): 37 | yield len(value) 38 | yield value.encode() 39 | elif isinstance(value, (list, tuple)): 40 | yield from (x for y in value for x in _iter_values(y)) 41 | elif isinstance(value, dict): 42 | yield from (x for y in value.values() for x in _iter_values(y)) 43 | else: 44 | yield value 45 | 46 | 47 | def _format_str(value): 48 | if value in (None, False, True): 49 | return "B" 50 | typ = type(value) 51 | if typ == int: 52 | return "!Bi" 53 | if typ == float: 54 | return "!Bf" 55 | if typ == str: 56 | return "i{}s".format(len(value)) 57 | if typ in (list, tuple, dict): 58 | return "!Bi" 59 | raise Exception("Unencodable value: {} (type={})".format(value, typ)) 60 | 61 | 62 | def calcsize(value): 63 | size = struct.calcsize(_format_str(value)) 64 | typ = type(value) 65 | if typ == str: 66 | size += len(value) 67 | elif typ in (list, tuple): 68 | for item in value: 69 | size += calcsize(item) 70 | elif typ == dict: 71 | for k, v in value.items(): 72 | size += calcsize(k) + calcsize(v) 73 | return size 74 | 75 | 76 | def _pack_into(buf, offset, value): 77 | typ = type(value) 78 | fmt = _format_str(value) 79 | if value in (None, False, True): 80 | struct.pack_into(fmt, buf, offset, _CONSTS[value]) 81 | elif typ == int: 82 | struct.pack_into(fmt, buf, offset, _INT_TYPE, value) 83 | elif typ == float: 84 | struct.pack_into(fmt, buf, offset, _FLOAT_TYPE, value) 85 | elif typ == str: 86 | struct.pack_into(fmt, buf, offset, len(value), value.encode()) 87 | elif typ in (list, tuple): 88 | struct.pack_into(fmt, buf, offset, _LIST_TYPE, len(value)) 89 | offset += struct.calcsize(fmt) 90 | for item in value: 91 | offset = _pack_into(buf, offset, item) 92 | return offset 93 | elif typ == dict: 94 | struct.pack_into(fmt, buf, offset, _MAP_TYPE, len(value)) 95 | offset += struct.calcsize(fmt) 96 | for k, v in value.items(): 97 | offset = _pack_into(buf, offset, k) 98 | offset = _pack_into(buf, offset, v) 99 | return offset 100 | return offset + calcsize(value) 101 | 102 | 103 | if __name__ == "__main__": 104 | import bno055_fake 105 | 106 | imu = bno055_fake.BNO055() 107 | data = { 108 | "machine_id": "abcd", 109 | "timestamp": 1234, 110 | "temperature": imu.temperature(), 111 | "accelerometer": imu.accelerometer(), 112 | "magnetometer": imu.magnetometer(), 113 | "gyroscope": imu.gyroscope(), 114 | "euler": imu.euler(), 115 | } 116 | print(dumps(data)) 117 | -------------------------------------------------------------------------------- /imu_tools/pub.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import json as json_enc 3 | import random 4 | import sys 5 | import time 6 | import uuid 7 | from math import cos, pi, sin 8 | 9 | import click 10 | import paho.mqtt.client as mqtt 11 | from loguru import logger 12 | 13 | from .config import mqtt_options 14 | 15 | logger.remove() 16 | logger.add(sys.stdout, format="{time:mm:ss.SS}: {message}", level="INFO") 17 | 18 | 19 | def gen_samples(axes=range(3)): 20 | def make_sample(t): 21 | s = t / 100 22 | frac = (t % 1000) / 1000 23 | euler = (pi / 10 * cos(1.2 * s), pi / 10 * cos(1.4 * s), s % (2 * pi)) 24 | euler = [x if i in axes else 0 for (i, x) in enumerate(euler)] 25 | return { 26 | "timestamp": int(time.time() * 1000), 27 | "accelerometer": (2048 * cos(s), 2048 * cos(1.2 * s), 2048 * cos(1.6 * s)), 28 | "calibration": 100, 29 | "euler": [e * 180 / pi for e in euler], 30 | "gyroscope": (40 + frac, 41 + frac, 42 + frac), 31 | "magnetometer": (30 + frac, 31 + frac, 32 + frac), 32 | "quaternion": euler2quat(*euler), 33 | "temperature": 27 + frac, 34 | } 35 | 36 | yield from map(make_sample, itertools.count(random.random())) 37 | 38 | 39 | def euler2quat(yaw, pitch, roll): 40 | c1, s1 = cos(yaw / 2), sin(yaw / 2) 41 | c2, s2 = cos(pitch / 2), sin(pitch / 2) 42 | c3, s3 = cos(roll / 2), sin(roll / 2) 43 | w = c1 * c2 * c3 - s1 * s2 * s3 44 | x = s1 * s2 * c3 + c1 * c2 * s3 45 | y = s1 * c2 * c3 + c1 * s2 * s3 46 | z = c1 * s2 * c3 - s1 * c2 * s3 47 | return (x, y, z, w) 48 | 49 | 50 | def iter_throttle(iterable, freq=20): 51 | next_time = 0 52 | for item in iterable: 53 | now = time.time() 54 | delay = next_time - now 55 | if delay > 0: 56 | time.sleep(delay) 57 | now = time.time() 58 | next_time = now + 1 / freq 59 | yield item 60 | 61 | 62 | @click.command() 63 | @mqtt_options 64 | @click.option( 65 | "--axis", 66 | default="0,1,2", 67 | help="An axis 0…2 or list of them. These Euler angles wil vary.", 68 | ) 69 | @click.option("--device-id", metavar="DEVICE_ID", default="{:x}".format(uuid.getnode())) 70 | @click.option( 71 | "--message", 72 | type=str, 73 | metavar="MESSAGE", 74 | help="Send MESSAGE instead of synthetic sensor readings", 75 | ) 76 | @click.option( 77 | "--rate", 78 | metavar="RATE", 79 | default=200, 80 | help="Messages per second for use with --continuous", 81 | ) 82 | @click.option("--continuous", is_flag=True, help="Keep sending messages at RATE/second") 83 | def main(*, user, host, port, password, device_id, axis, message, continuous, rate): 84 | """Send MQTT messages. 85 | 86 | In a MESSAGE string, {i} is replaced by the message count, and {time} by the 87 | Epoch seconds. This is useful in combination with --continuous. 88 | """ 89 | 90 | def on_publish(_client, _userdata, message_id): 91 | mqtt_url = f"tcp://{host}:{port}/{topic}" 92 | logger.info("Published(id={}) to {}", message_id, mqtt_url) 93 | 94 | topic = f"imu/{device_id}" 95 | client = mqtt.Client() 96 | client.on_publish = on_publish 97 | 98 | client.on_log = True 99 | if user: 100 | client.username_pw_set(user, password=password) 101 | try: 102 | client.connect(host, port) 103 | except ConnectionRefusedError as err: 104 | print(err, f"connecting to {user}@{host}:{port}") 105 | sys.exit(1) 106 | 107 | axes = list(map(int, axis.split(","))) 108 | samples = ( 109 | (message.format(i=i, time=time.time()) for i in itertools.count()) 110 | if message is not None 111 | else map(json_enc.dumps, gen_samples(axes)) 112 | ) 113 | if not continuous: 114 | samples = itertools.islice(samples, 1) 115 | 116 | for payload in iter_throttle(samples, rate): 117 | info = client.publish(topic, payload=payload) 118 | client.disconnect() 119 | info.wait_for_publish() 120 | 121 | 122 | if __name__ == "__main__": 123 | main() # pylint: disable=missing-kwoa 124 | -------------------------------------------------------------------------------- /pyboard/bno055.py: -------------------------------------------------------------------------------- 1 | import ustruct 2 | import utime 3 | from micropython import const 4 | 5 | from ufunctools import partial 6 | 7 | _CHIP_ID = const(0xA0) 8 | 9 | CONFIG_MODE = const(0x00) 10 | ACCONLY_MODE = const(0x01) 11 | MAGONLY_MODE = const(0x02) 12 | GYRONLY_MODE = const(0x03) 13 | ACCMAG_MODE = const(0x04) 14 | ACCGYRO_MODE = const(0x05) 15 | MAGGYRO_MODE = const(0x06) 16 | AMG_MODE = const(0x07) 17 | IMUPLUS_MODE = const(0x08) 18 | COMPASS_MODE = const(0x09) 19 | M4G_MODE = const(0x0A) 20 | NDOF_FMC_OFF_MODE = const(0x0B) 21 | NDOF_MODE = const(0x0C) 22 | 23 | _POWER_NORMAL = const(0x00) 24 | _POWER_LOW = const(0x01) 25 | _POWER_SUSPEND = const(0x02) 26 | 27 | # _MODE_REGISTER = const(0x3d) 28 | 29 | 30 | class BNO055: 31 | def __init__(self, i2c, address=0x28, verbose=False): 32 | self.i2c = i2c 33 | self.buffer = bytearray(2) 34 | self.address = address 35 | self._is_verbose = verbose 36 | self.init() 37 | 38 | def _verbose(self, *args): 39 | if self._is_verbose: 40 | print(*args) 41 | 42 | def _write_register(self, register, value): 43 | self.buffer[0] = register 44 | self.buffer[1] = value 45 | self._verbose("i2c.write", hex(register), "<-", value) 46 | with self.i2c as i2c: 47 | i2c.write(self.buffer) 48 | 49 | def _registers(self, register, struct, value=None, scale=1): 50 | if value is None: 51 | size = ustruct.calcsize(struct) 52 | self._verbose("i2c.read", hex(register), "->") 53 | data = self.i2c.readfrom_mem(self.address, register, size) 54 | value = ustruct.unpack(struct, data) 55 | self._verbose(" ", data) 56 | if scale != 1: 57 | value = tuple(v * scale for v in value) 58 | return value 59 | if scale != 1: 60 | value = tuple(v / scale for v in value) 61 | data = ustruct.pack(struct, *value) 62 | self._verbose("i2c.write", hex(register), "<-", data) 63 | self.i2c.writeto_mem(self.address, register, data) 64 | 65 | def _register(self, value=None, register=0x00, struct="B"): 66 | if value is None: 67 | return self._registers(register, struct=struct)[0] 68 | self._registers(register, struct=struct, value=(value,)) 69 | 70 | _chip_id = partial(_register, register=0x00, value=None) 71 | _power_mode = partial(_register, register=0x3E) 72 | _system_trigger = partial(_register, register=0x3F) 73 | _page_id = partial(_register, register=0x07) 74 | operation_mode = partial(_register, register=0x3D) 75 | temperature = partial(_register, register=0x34, value=None) 76 | calibration = partial(_register, register=0x35, struct=" { 8 | const now = new Date(); 9 | const { deviceId } = device; 10 | const { receivedAt: timestamp } = data; 11 | const timestamps = [ 12 | timestamp, 13 | ...(deviceMap[deviceId] ? deviceMap[deviceId].timestamps : []), 14 | ].filter((ts) => now - ts < 1000); 15 | deviceMap[deviceId] = { device, timestamp, timestamps }; 16 | }); 17 | 18 | function App() { 19 | const [devices, setDevices] = useState([]); 20 | const [editDeviceId, setEditDeviceId] = useState(); 21 | 22 | useEffect(() => { 23 | const id = setInterval(() => setDevices(Object.values(deviceMap)), 100); 24 | return () => clearInterval(id); 25 | }, []); 26 | 27 | return devices.length === 0 ? ( 28 |
No devices are online
29 | ) : ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {devices.map((record) => { 39 | const { deviceId } = record.device; 40 | return ( 41 | 46 | setEditDeviceId(flag ? deviceId : null) 47 | } 48 | /> 49 | ); 50 | })} 51 | 52 |
Device IDNameSample RateLast Seen
53 | ); 54 | } 55 | 56 | function Device({ 57 | record: { device, timestamp, timestamps }, 58 | isEditing, 59 | setEditing, 60 | }) { 61 | const now = new Date(); 62 | const { deviceId } = device; 63 | const brightness = Math.min( 64 | 0.8, 65 | Math.max(0, +new Date() - timestamp - 250) / 5000 66 | ); 67 | const color = `hsl(0,0%,${100 * brightness}%)`; 68 | const frameRate = timestamps.filter((n) => n > now - 1000).length; 69 | function setGlobal() { 70 | window.device = device; 71 | } 72 | return ( 73 | 74 | 75 | {deviceId} 76 | 77 | 78 | device.setDeviceName(name)} 83 | value={device.deviceName} 84 | /> 85 | 86 | {frameRate} 87 | {ageString(DateTime.fromMillis(timestamp))} 88 | 89 | ); 90 | } 91 | 92 | function Editable({ value, editable, isEditing, setEditing, onChange }) { 93 | function commitChange({ target }) { 94 | onChange(target.value); 95 | setEditing(false); 96 | } 97 | function handleKey({ key, target }) { 98 | switch (key) { 99 | case 'Enter': 100 | commitChange({ target }); 101 | break; 102 | case 'Escape': 103 | setEditing(false); 104 | break; 105 | } 106 | } 107 | return isEditing ? ( 108 | 114 | ) : ( 115 |
setEditing(editable)} 117 | className={value ? '' : 'empty'} 118 | > 119 | {value || ''} 120 |
121 | ); 122 | } 123 | 124 | function ageString(when) { 125 | const now = DateTime.fromJSDate(new Date()); 126 | const age = Duration.fromMillis(now - when); 127 | if (age < 1000) { 128 | return 'now'; 129 | } 130 | if (age.shiftTo('minutes').minutes < 2) { 131 | return age.toFormat(`s 'seconds' ago`); 132 | } 133 | if (age.shiftTo('minutes').minutes < 10) { 134 | return age.toFormat(`m 'minutes' ago`); 135 | } 136 | if (when.day === now.day && age.shiftTo('days').days < 1) { 137 | return when.toLocaleString(DateTime.TIME_24_WITH_SHORT_OFFSET); 138 | } 139 | return when.toLocaleString(DateTime.DATETIME_SHORT); 140 | } 141 | 142 | ReactDOM.render(, document.getElementById('device-list')); 143 | -------------------------------------------------------------------------------- /imu_tools/sub.py: -------------------------------------------------------------------------------- 1 | import json 2 | import queue 3 | import subprocess 4 | import sys 5 | import time 6 | from pathlib import Path 7 | from queue import Queue 8 | 9 | import click 10 | import paho.mqtt.client as mqtt 11 | from loguru import logger 12 | 13 | from .config import mqtt_options 14 | 15 | logger.remove() 16 | logger.add(sys.stdout, format="{time:mm:ss.SS}: {message}", level="INFO") 17 | 18 | PIPE_PATH = "/tmp/imu-relay.pipe" 19 | 20 | # Called on a CONNACK response from the server. 21 | def make_on_connect(topic="#"): 22 | def on_connect(client, userdata, _flags, rc): 23 | hostname = userdata["hostname"] 24 | logger.info("Connected(host={}, rc={})", hostname, rc) 25 | client.subscribe(topic) 26 | 27 | return on_connect 28 | 29 | 30 | def on_subscribe(_client, _userdata, mid, granted_qos): 31 | logger.info("Subscribed(id={} qos={})", mid, list(granted_qos)) 32 | 33 | 34 | def sample_rate_reporter_gen(sample_period=10): 35 | sample_start_time, sample_count = time.time(), 0 36 | show_frame_rate = True 37 | while True: 38 | msg = yield 39 | if msg: 40 | sample_count += 1 41 | now = time.time() 42 | elapsed = now - sample_start_time 43 | # print(".", elapsed, end="", flush=True) 44 | if elapsed >= sample_period: 45 | if show_frame_rate: 46 | # logger.info("{:0.1f} samples/sec", samples / elapsed) 47 | logger.info("{:0.1f} samples/sec", sample_count / elapsed) 48 | sample_start_time = now 49 | sample_count = 0 50 | 51 | 52 | # Called when a PUBLISH message is received from the server. 53 | def on_message(_client, userdata, msg): 54 | message_queue = userdata["queue"] 55 | try: 56 | message_queue.put(msg) 57 | except StopIteration: 58 | sys.exit(1) 59 | except Exception as err: # pylint: disable=broad-except 60 | print(f"MQTT on_message error: {err}", file=sys.stderr, flush=True) 61 | 62 | 63 | def print_message(msg, *, only=None, output=None): 64 | if not msg: 65 | return 66 | data = msg.payload 67 | if data and data[0] == ord("{"): 68 | try: 69 | data = data.decode() 70 | except UnicodeError: 71 | pass # use the undecoded payload 72 | try: 73 | data = json.loads(data) 74 | except json.JSONDecodeError: 75 | pass # use the undecoded payload 76 | if output: 77 | qs = data["quaternion"] 78 | print("quaternion: " + ", ".join(map(str, qs)), file=output, flush=True) 79 | if only: 80 | if only not in data: 81 | return 82 | data = data[only] 83 | logger.info("Message(topic={}): {}", msg.topic, data) 84 | 85 | 86 | def create_output_pipe(): 87 | pipe_path = Path(PIPE_PATH) 88 | if not pipe_path.exists(): 89 | print("Creating named pipe", pipe_path) 90 | subprocess.run(["mkfifo", pipe_path], check=True) 91 | return open(pipe_path, "w") 92 | 93 | 94 | @click.command() 95 | @mqtt_options 96 | @click.option("--only", metavar="FIELD", help="Print only the specified field") 97 | @click.option("--pipe", is_flag=True, help=f"Pipe quaternions to {PIPE_PATH}") 98 | @click.option( 99 | "--device-id", metavar="DEVICE_ID", help=f"Only subscribe to device DEVICE_ID" 100 | ) 101 | @click.option( 102 | "--sample-rate", is_flag=True, help="Print the sample rate instead of the samples" 103 | ) 104 | @click.option("--sample-period", default=1.0, metavar="SECONDS") 105 | def main( 106 | *, user, host, port, password, device_id, only, sample_rate, sample_period, pipe 107 | ): 108 | """Relay MQTT messages to standard output, and optionally to a named pipe.""" 109 | output_pipe = create_output_pipe() if pipe else None 110 | reporter = sample_rate_reporter_gen(sample_period) 111 | next(reporter) 112 | message_queue = Queue() 113 | userdata = dict(hostname=host, queue=message_queue) 114 | 115 | client = mqtt.Client(userdata=userdata) 116 | client.on_connect = make_on_connect(f"imu/{device_id}" if device_id else "#") 117 | client.on_message = on_message 118 | client.on_subscribe = on_subscribe 119 | client.on_log = True 120 | 121 | if user: 122 | client.username_pw_set(user, password=password) 123 | client.connect(host, port) 124 | 125 | try: 126 | client.loop_start() 127 | while True: 128 | try: 129 | message = message_queue.get(timeout=1) 130 | except queue.Empty: 131 | message = None 132 | if sample_rate: 133 | reporter.send(message) 134 | elif message: 135 | print_message(message, only=only, output=output_pipe) 136 | # ensure that no-sample sample rate is reported 137 | # sleep(sample_period) 138 | # reporter.send(None) 139 | except KeyboardInterrupt: 140 | pass 141 | 142 | 143 | if __name__ == "__main__": 144 | main() # pylint: disable=missing-kwoa 145 | -------------------------------------------------------------------------------- /scripts/ble2mqtt.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env npx ts-node 2 | import * as mqtt from "mqtt"; 3 | import { Bluetooth } from "webbluetooth"; 4 | 5 | const BLE_MAC_ADDRESS_SERVICE_UUID = "709f0001-37e3-439e-a338-23f00067988b"; 6 | const BLE_MAC_ADDRESS_CHAR_UUID = "709f0002-37e3-439e-a338-23f00067988b"; 7 | const BLE_DEVICE_NAME_CHAR_UUID = "709f0003-37e3-439e-a338-23f00067988b"; 8 | 9 | const BLE_IMU_SERVICE_UUID = "509b0001-ebe1-4aa5-bc51-11004b78d5cb"; 10 | const BLE_IMU_SENSOR_CHAR_UUID = "509b0002-ebe1-4aa5-bc51-11004b78d5cb"; 11 | const BLE_IMU_CALIBRATION_CHAR_UUID = "509b0003-ebe1-4aa5-bc51-11004b78d5cb"; 12 | 13 | const MQTT_URL = "mqtt://localhost"; 14 | const LOG_MESSAGE_PUBLISH = false; 15 | 16 | const DEC = new TextDecoder(); 17 | 18 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 19 | 20 | const mqttClient = mqtt.connect(MQTT_URL); 21 | const mqttConnectionPromise = new Promise((resolve) => 22 | mqttClient.on("connect", resolve) 23 | ); 24 | 25 | // Run forever, scanning for BLE devices. 26 | async function requestDevices( 27 | options: RequestDeviceOptions, 28 | callback: (_: BluetoothDevice) => void, 29 | msBetweenScans = 1000 30 | ) { 31 | const seenDeviceIds = new Set(); 32 | const deviceFound = (device: BluetoothDevice, _selectFn: () => void) => 33 | !seenDeviceIds.has(device.id); 34 | const bluetooth = new Bluetooth({ deviceFound }); 35 | while (true) { 36 | console.info("Scanning BLE..."); 37 | const device = await bluetooth.requestDevice(options).catch((err) => { 38 | if (!err.match(/\bno devices found\b/)) throw err; 39 | }); 40 | if (device) { 41 | const deviceId = device.id; 42 | console.log(`Connected to BLE device id=${deviceId}`); 43 | seenDeviceIds.add(deviceId); 44 | device.addEventListener("gattserverdisconnected", () => { 45 | console.log(`Disconnected from BLE device id=${deviceId}`); 46 | seenDeviceIds.delete(deviceId); 47 | }); 48 | callback(device); 49 | } 50 | await sleep(msBetweenScans); 51 | } 52 | } 53 | 54 | async function relayMessages(server: BluetoothRemoteGATTServer) { 55 | const { deviceName, deviceId } = await subscribeMacAddressService(server); 56 | console.log(`${deviceId}: ${deviceName}`); 57 | const notifier = await subscribeImuService(server); 58 | notifier.listen((buffer) => { 59 | const topic = `imu/${deviceId}`; 60 | let payload = Buffer.from(buffer); 61 | if (LOG_MESSAGE_PUBLISH) console.log("publish", topic, payload); 62 | mqttClient.publish(topic, payload); 63 | }); 64 | } 65 | 66 | async function subscribeMacAddressService(server: BluetoothRemoteGATTServer) { 67 | const macAddressService = await server.getPrimaryService( 68 | BLE_MAC_ADDRESS_SERVICE_UUID 69 | ); 70 | 71 | const deviceIdChar = await macAddressService.getCharacteristic( 72 | BLE_MAC_ADDRESS_CHAR_UUID 73 | ); 74 | const deviceId = DEC.decode(await deviceIdChar.readValue()); 75 | 76 | const deviceNameChar = await macAddressService.getCharacteristic( 77 | BLE_DEVICE_NAME_CHAR_UUID 78 | ); 79 | const deviceName = DEC.decode(await deviceNameChar.readValue()); 80 | return { deviceId, deviceName }; 81 | } 82 | 83 | /* 84 | * BLE IMU Service 85 | */ 86 | 87 | interface BluetoothRemoteGATTCharacteristicEventTarget { 88 | value: DataView; 89 | } 90 | 91 | async function subscribeImuService(server: BluetoothRemoteGATTServer) { 92 | const imuService = await server.getPrimaryService(BLE_IMU_SERVICE_UUID); 93 | 94 | const imuCalibrationChar = await imuService.getCharacteristic( 95 | BLE_IMU_CALIBRATION_CHAR_UUID 96 | ); 97 | let calibrationView = await imuCalibrationChar.readValue(); 98 | let calibrationValue = calibrationView.getUint8(0); 99 | await imuCalibrationChar.startNotifications(); 100 | imuCalibrationChar.addEventListener( 101 | "characteristicvaluechanged", 102 | ({ target }) => { 103 | const bleTarget = ( 104 | (target) 105 | ); 106 | calibrationValue = bleTarget.value.getUint8(0); 107 | console.log("calibration", calibrationValue); 108 | } 109 | ); 110 | 111 | const imuSensorChar = await imuService.getCharacteristic( 112 | BLE_IMU_SENSOR_CHAR_UUID 113 | ); 114 | const listeners = []; 115 | await imuSensorChar.startNotifications(); 116 | imuSensorChar.addEventListener("characteristicvaluechanged", ({ target }) => { 117 | const bleTarget = ( 118 | (target) 119 | ); 120 | const { buffer } = bleTarget.value; 121 | listeners.forEach((fn) => fn(buffer)); 122 | }); 123 | return { 124 | listen: (fn: (_: Buffer) => void) => listeners.push(fn), 125 | }; 126 | } 127 | 128 | async function main() { 129 | await mqttConnectionPromise; 130 | console.log(`Connected to ${MQTT_URL}`); 131 | const options = { filters: [{ services: [BLE_IMU_SERVICE_UUID] }] }; 132 | await requestDevices(options, async (device) => { 133 | const server = await device.gatt.connect(); 134 | relayMessages(server); 135 | }); 136 | } 137 | 138 | (async () => { 139 | try { 140 | await main(); 141 | } catch (error) { 142 | console.log(error); 143 | } 144 | process.exit(0); 145 | })(); 146 | -------------------------------------------------------------------------------- /pyboard/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | 5 | import machine 6 | import network 7 | import sensors 8 | import utime as time 9 | from machine import Pin 10 | from umqtt.simple import MQTTClient 11 | 12 | import config 13 | import webserver 14 | 15 | DEVICE_ID = "".join(map("{:02x}".format, machine.unique_id())) 16 | SENSORS = sensors.get_imu(use_dummy=config.USE_DUMMY_IMU) 17 | 18 | print("Device id =", DEVICE_ID) 19 | 20 | 21 | # 22 | # MQTT Connection 23 | # 24 | MQTT_CLIENT = None 25 | 26 | 27 | def mqtt_connect(options): 28 | mqtt_host = options["host"] 29 | mqtt_client = MQTTClient( 30 | DEVICE_ID, 31 | mqtt_host, 32 | port=options["port"], 33 | user=options["user"], 34 | password=options["password"], 35 | ) 36 | broker_url = ( 37 | "mqtt://{user}@{host}:{port}/".format(**options) 38 | .replace("//@", "") 39 | .replace(":1883/", "/") 40 | ) 41 | print("Connecting to " + broker_url, end="...") 42 | try: 43 | mqtt_client.connect() 44 | print("done.") 45 | publish_machine_identifier(mqtt_client) 46 | mqtt_client.set_callback(on_mqtt_message) 47 | mqtt_client.subscribe("imu/control/" + DEVICE_ID) 48 | mqtt_client.subscribe("imu/control/*") 49 | except OSError as err: 50 | print(err) 51 | return mqtt_client 52 | 53 | 54 | def publish_machine_identifier(mqtt_client): 55 | data = { 56 | "platform": sys.platform, 57 | "sysname": os.uname().sysname, 58 | "nodename": os.uname().nodename, 59 | "machine": os.uname().machine, 60 | "machine_freq": machine.freq(), 61 | "timestamp": time.ticks_ms(), 62 | } 63 | mqtt_client.publish("imu/" + DEVICE_ID, json.dumps(data)) 64 | 65 | 66 | def on_mqtt_message(_topic, msg): 67 | command = msg.decode() 68 | print("Received:", command) 69 | 70 | 71 | def publish_sensor_data(data): 72 | """Publish the sensor data to MQTT, and also to the serial port. If no IMU is 73 | present, publish the system identification instead. 74 | 75 | If config.SEND_SERIAL_SENSOR_DATA is set, send the data on the serial port. 76 | """ 77 | payload = json.dumps(data) 78 | if MQTT_CLIENT: 79 | MQTT_CLIENT.publish("imu/" + DEVICE_ID, payload) 80 | 81 | 82 | def send_serial_data(data): 83 | print(";".join(k + "=" + str(v) for k, v in zip(["rx", "ry", "rz"], data["euler"]))) 84 | 85 | 86 | # 87 | # WiFi Connection 88 | # 89 | 90 | 91 | def connect_to_wifi(options): 92 | global MQTT_CLIENT 93 | station = network.WLAN(network.STA_IF) 94 | if station.isconnected(): 95 | if options.RUN_HTTP_SERVER: 96 | webserver.start_http_server(station) 97 | if options.SEND_MQTT_SENSOR_DATA: 98 | MQTT_CLIENT = mqtt_connect(options.MQTT_CONFIG) 99 | 100 | 101 | def sample_rate_gen(): 102 | sample_start_time, sample_count = time.time(), 0 103 | sample_period = 10 104 | while True: 105 | yield 106 | sample_count += 1 107 | current_time = time.time() 108 | if current_time - sample_start_time >= sample_period: 109 | sample_rate = sample_count / (current_time - sample_start_time) 110 | print("{:02d}:{:02d}:{:02d}".format(*time.localtime()[3:6]), end=": ") 111 | print("{:0.1f} samples/sec".format(sample_rate)) 112 | sample_start_time = current_time 113 | sample_count = 0 114 | 115 | 116 | def blinker_gen(pin_number=2): 117 | led = Pin(pin_number, Pin.OUT) 118 | next_blink_ms = 0 119 | while True: 120 | yield 121 | if time.ticks_ms() > next_blink_ms: 122 | next_blink_ms = time.ticks_ms() + 1000 123 | led.value(led.value()) 124 | 125 | 126 | def loop_forever(options, mqtt_client): 127 | sample_rate_iter = sample_rate_gen() 128 | # blink_iter = blinker_gen() 129 | while True: 130 | # Publish the sensor data each time through the loop. 131 | # next(blink_iter) 132 | # if config.SEND_SERIAL_SENSOR_DATA and select.select([sys.stdin], [], [], 0)[0]: 133 | # cmd = sys.stdin.readline().strip() 134 | # print("# cmd =", repr(cmd)) 135 | # if cmd.startswith(":ping "): 136 | # sequence_id = cmd.split(" ")[1] 137 | # print("!pong " + sequence_id) 138 | # elif cmd == ":ping": 139 | # print("!pong") 140 | # elif cmd == ":device_id?": 141 | # print("!device_id=" + DEVICE_ID) 142 | if mqtt_client: 143 | mqtt_client.check_msg() 144 | sensor_data = sensors.get_sensor_data(SENSORS) 145 | if not sensor_data: 146 | continue 147 | if options.SEND_MQTT_SENSOR_DATA: 148 | publish_sensor_data(sensor_data) 149 | if options.SEND_SERIAL_SENSOR_DATA: 150 | send_serial_data(sensor_data) 151 | else: 152 | next(sample_rate_iter) 153 | if options.RUN_HTTP_SERVER: 154 | webserver.service_http_request( 155 | mqtt_client=mqtt_client, sensor_data=sensor_data 156 | ) 157 | 158 | 159 | connect_to_wifi(config) 160 | loop_forever(config, mqtt_client=MQTT_CLIENT) 161 | -------------------------------------------------------------------------------- /web/js/3d-model.js: -------------------------------------------------------------------------------- 1 | import { onSensorData, bleAvailable, bleConnect } from './imu-connection.js'; 2 | import { isMobile, quatToMatrix } from './utils.js'; 3 | 4 | let modelObj; // setup initializes this to a p5.js 3D model 5 | const devices = {}; // sensor data for each device, indexed by device id 6 | 7 | const AXIS_LENGTH = 400; 8 | 9 | const settings = { 10 | draw_axes: false, 11 | dx: 0, 12 | dy: 0, 13 | dz: 0, 14 | rx: 0, 15 | ry: 0, 16 | rz: 180, 17 | model_name: 'bunny', 18 | }; 19 | 20 | // Constants for physics simulation 21 | const SPRING_LENGTH = 500; 22 | const SPRING_K = 0.001; // strength of spring between bodies 23 | const ORIGIN_SPRING_K = 0.99; // strength of spring towards origin 24 | const VISCOSITY = 0.99; 25 | 26 | function loadModelFromSettings() { 27 | let modelName = settings.model_name || 'bunny'; 28 | if (!modelName.match(/\.(obj|stl)$/)) { 29 | modelName += '.obj'; 30 | } 31 | modelObj = loadModel('models/' + modelName, true); 32 | } 33 | 34 | var datControllers = {}; 35 | if (window.dat && !isMobile) { 36 | const gui = new dat.GUI(); 37 | // gui.remember(settings); // uncomment to store settings to localStorage 38 | gui.add(settings, 'draw_axes').name('Draw axes'); 39 | gui.add(settings, 'dx', -300, 300).name('x displacement'); 40 | gui.add(settings, 'dy', -300, 300).name('y displacement'); 41 | gui.add(settings, 'dz', -300, 300).name('z displacement'); 42 | datControllers = { 43 | rx: gui.add(settings, 'rx', 0, 180).name('x rotation'), 44 | ry: gui.add(settings, 'ry', 0, 180).name('y rotation'), 45 | rz: gui.add(settings, 'rz', 0, 360).name('z rotation'), 46 | }; 47 | gui.add(settings, 'model_name') 48 | .name('Model name') 49 | .onFinishChange(loadModelFromSettings); 50 | } 51 | 52 | export function setup() { 53 | createCanvas(windowWidth, windowHeight, WEBGL); 54 | loadModelFromSettings(); 55 | createButton('Calibrate').position(0, 0).mousePressed(calibrateModels); 56 | } 57 | 58 | export function draw() { 59 | const currentTime = +new Date(); 60 | 61 | background(200, 200, 212); 62 | noStroke(); 63 | lights(); 64 | orbitControl(); 65 | 66 | const models = Object.values(devices); 67 | // apply the physics simulation just to the models that have recent sensor data 68 | updatePhysics( 69 | models.filter(({ receivedAt }) => currentTime - receivedAt < 500) 70 | ); 71 | 72 | models.forEach((data) => { 73 | push(); 74 | // Place the object in world coordinates 75 | if (data.position) { 76 | translate.apply(null, data.position); 77 | } 78 | 79 | if (data.calibrationMatrix) { 80 | applyMatrix.apply(null, data.calibrationMatrix); 81 | } 82 | 83 | applyMatrix.apply(null, data.orientationMatrix); 84 | 85 | // Draw the axes in model coordinates 86 | if (settings.draw_axes) { 87 | drawAxes(); 88 | } 89 | 90 | // Fade the model out, if the sensor data is stale 91 | const age = Math.max(0, currentTime - data.receivedAt - 250); 92 | const alpha = Math.max(5, 255 - age / 10); 93 | fill(255, 255, 255, alpha); 94 | 95 | // Fully uncalibrated models are shown in red 96 | if (data.calibration === 0) { 97 | fill(255, 0, 0, alpha); 98 | } 99 | 100 | // Apply the GUI rotation settings 101 | rotateX((settings.rx * Math.PI) / 180); 102 | rotateY((settings.ry * Math.PI) / 180); 103 | rotateZ((settings.rz * Math.PI) / 180); 104 | 105 | // Translate the position in model coordinates. This swings it around 106 | // the end of a stick. 107 | translate(settings.dx, settings.dy, settings.dz); 108 | 109 | // Render the model 110 | noStroke(); 111 | model(modelObj); 112 | 113 | pop(); 114 | }); 115 | } 116 | 117 | // Set its model's calibration matrix to the inverse of the model's current orientation. 118 | // This will cause it to be drawn in its native orientation whenever 119 | function calibrateModels() { 120 | const models = Object.values(devices); 121 | models.forEach((model) => { 122 | const [q0, q1, q2, q3] = model.quaternion; 123 | const mat = quatToMatrix(q3, q1, q0, q2); 124 | const inv = math.inv([ 125 | mat.slice(0, 3), 126 | mat.slice(4, 7), 127 | mat.slice(8, 11), 128 | ]); 129 | model.calibrationMatrix = [ 130 | ...inv[0], 131 | 0, 132 | ...inv[1], 133 | 0, 134 | ...inv[2], 135 | 0, 136 | ...[0, 0, 0, 1], 137 | ]; 138 | }); 139 | // reset the GUI rotation, and update the GUI slider display 140 | settings.rx = 0; 141 | settings.ry = 0; 142 | settings.rz = 0; 143 | Object.values(datControllers).forEach((c) => c.updateDisplay()); 144 | } 145 | 146 | function drawAxes() { 147 | strokeWeight(3); 148 | [0, 1, 2].forEach((axis) => { 149 | const color = [0, 0, 0]; 150 | const vector = [0, 0, 0, 0, 0, 0]; 151 | color[axis] = 128; 152 | vector[axis + 3] = AXIS_LENGTH; 153 | stroke.apply(null, color); 154 | line.apply(null, vector); 155 | }); 156 | } 157 | 158 | function updatePhysics(models) { 159 | // initialize positions and velocities of new models 160 | models.forEach((data) => { 161 | if (!data.position) { 162 | // Offset models from the origin so they disperse 163 | const e = 0.0001; 164 | const rand = () => (Math.random() - 0.5) * 2 * e; 165 | data.position = [rand(), rand(), rand()]; 166 | data.velocity = [0, 0, 0]; 167 | } 168 | }); 169 | 170 | // Apply spring forces between every object pair 171 | models.forEach((d1) => { 172 | models.forEach((d2) => { 173 | if (d1 === d2) { 174 | return; 175 | } 176 | const v = d1.position.map((p0, i) => d2.position[i] - p0); 177 | const len = Math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2); 178 | const v_norm = v.map((x) => x / len); 179 | const f = SPRING_K * (len - SPRING_LENGTH); 180 | const fv = v_norm.map((x) => x * f); 181 | d1.velocity = d1.velocity.map((x, i) => x + fv[i]); 182 | d2.velocity = d2.velocity.map((x, i) => x - fv[i]); 183 | }); 184 | }); 185 | 186 | // Add velocities to positions. Spring positions to origin. Damp velocities. 187 | models.forEach((data) => { 188 | const { position, velocity } = data; 189 | data.position = position.map( 190 | (x, i) => (x + velocity[i]) * ORIGIN_SPRING_K 191 | ); 192 | data.velocity = velocity.map((v) => v * VISCOSITY); 193 | }); 194 | } 195 | 196 | onSensorData( 197 | ({ deviceId, data }) => 198 | (devices[deviceId] = { 199 | ...(devices[deviceId] || {}), 200 | ...data, 201 | }) 202 | ); 203 | 204 | export function keyPressed(evt) { 205 | if (evt.key.match(/b/i) && bleAvailable) { 206 | bleConnect(); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /web/js/ble-client.js: -------------------------------------------------------------------------------- 1 | import { decodeSensorData } from './sensor-encoding.js'; 2 | 3 | const BLE_MAC_ADDRESS_SERVICE_UUID = '709f0001-37e3-439e-a338-23f00067988b'; 4 | const BLE_MAC_ADDRESS_CHAR_UUID = '709f0002-37e3-439e-a338-23f00067988b'; 5 | const BLE_DEVICE_NAME_CHAR_UUID = '709f0003-37e3-439e-a338-23f00067988b'; 6 | 7 | const BLE_IMU_SERVICE_UUID = '509b0001-ebe1-4aa5-bc51-11004b78d5cb'; 8 | const BLE_IMU_SENSOR_CHAR_UUID = '509b0002-ebe1-4aa5-bc51-11004b78d5cb'; 9 | const BLE_IMU_CALIBRATION_CHAR_UUID = '509b0003-ebe1-4aa5-bc51-11004b78d5cb'; 10 | 11 | const ENC = new TextEncoder(); 12 | const DEC = new TextDecoder(); 13 | 14 | const onSensorDataCallbacks = []; 15 | 16 | /** Connect to a BLE IMU peripheral. */ 17 | export async function connect() { 18 | const bleDevice = await navigator.bluetooth.requestDevice({ 19 | // acceptAllDevices: true, 20 | filters: [{ services: [BLE_IMU_SERVICE_UUID] }], 21 | optionalServices: [ 22 | BLE_IMU_SERVICE_UUID, 23 | BLE_MAC_ADDRESS_SERVICE_UUID, 24 | ], 25 | }); 26 | const server = await bleDevice.gatt.connect(); 27 | document.body.className += ' connected'; 28 | bleDevice.addEventListener('gattserverdisconnected', onDisconnected); 29 | 30 | let { deviceId, deviceName, setDeviceName, deviceNameChangeNotifier } = await subscribeMacAddressService(server); 31 | const device = { deviceId, deviceName, setDeviceName, bleDevice }; 32 | deviceNameChangeNotifier.listen(name => { device.deviceName = name }); 33 | 34 | let imuDataNotifier = await subscribeImuService(server); 35 | imuDataNotifier.listen(data => { 36 | const record = { 37 | deviceId, 38 | device, 39 | data, 40 | }; 41 | onSensorDataCallbacks.forEach(fn => fn(record)); 42 | }); 43 | } 44 | 45 | async function subscribeServices(server, services) { 46 | const result = {}; 47 | for (const entry of Object.entries(services)) { 48 | const [serviceUuid, chars] = entry; 49 | const service = await server.getPrimaryService(serviceUuid); 50 | result[serviceUuid] = {}; 51 | for (const entry of Object.entries(chars)) { 52 | const [charUuid, spec] = entry; 53 | let { decoder, encoder, format } = spec; 54 | switch (format) { 55 | case 's': 56 | decoder = decoder || (value => DEC.decode(value)); 57 | encoder = encoder || (value => ENC.encode(value)); 58 | break; 59 | } 60 | decoder = decoder || (value => value); 61 | encoder = encoder || (value => value); 62 | const char = await service.getCharacteristic(charUuid); 63 | const listeners = []; 64 | let subscribed = false; 65 | result[serviceUuid][charUuid] = { 66 | async get() { 67 | const value = await char.readValue(); 68 | return decoder(value); 69 | }, 70 | async set(value) { 71 | await char.writeValue(encoder(value)); 72 | const decoded = decoder(await char.readValue()); 73 | listeners.forEach((fn) => fn(decoded)); 74 | }, 75 | async onChange(fn) { 76 | if (!subscribed && !char.properties.write) { 77 | subscribed = true; 78 | await char.startNotifications(); 79 | char.addEventListener('characteristicvaluechanged', ({ target: { value } }) => { 80 | const decoded = decoder(value); 81 | listeners.forEach((fn) => fn(decoded)); 82 | }); 83 | } 84 | listeners.push(fn); 85 | }, 86 | } 87 | } 88 | } 89 | return result; 90 | } 91 | 92 | async function subscribeMacAddressService(server) { 93 | let { 94 | [BLE_MAC_ADDRESS_SERVICE_UUID]: { 95 | [BLE_MAC_ADDRESS_CHAR_UUID]: { get: deviceIdPromise }, 96 | [BLE_DEVICE_NAME_CHAR_UUID]: { get: deviceNamePromise, set: setDeviceName, onChange: deviceNameChangeNotifier }, 97 | } 98 | } = await subscribeServices(server, { 99 | [BLE_MAC_ADDRESS_SERVICE_UUID]: { 100 | [BLE_MAC_ADDRESS_CHAR_UUID]: { format: 's' }, 101 | [BLE_DEVICE_NAME_CHAR_UUID]: { format: 's' }, 102 | }, 103 | }); 104 | let deviceId = await deviceIdPromise(); 105 | let deviceName = await deviceNamePromise(); 106 | return { 107 | deviceId, deviceName, setDeviceName, deviceNameChangeNotifier: { 108 | listen: deviceNameChangeNotifier 109 | } 110 | }; 111 | } 112 | 113 | function onDisconnected() { 114 | document.body.className = document.body.className.replace( 115 | /(\s|^)connected(\s|$)/, 116 | '' 117 | ); 118 | } 119 | 120 | export async function disconnect() { 121 | server.disconnect(); 122 | } 123 | 124 | const withConsoleErrors = (fn) => (args) => fn.apply(null, args); 125 | // fn.apply(null, args).catch(err => console.error(err)); 126 | 127 | /* 128 | * BLE IMU Service 129 | */ 130 | 131 | async function subscribeImuService(server) { 132 | let { 133 | [BLE_IMU_SERVICE_UUID]: { 134 | [BLE_IMU_CALIBRATION_CHAR_UUID]: { get: calibrationP, onChange: onCalibrationChange }, 135 | [BLE_IMU_SENSOR_CHAR_UUID]: { onChange: onSensorChange }, 136 | } 137 | } = await subscribeServices(server, { 138 | [BLE_IMU_SERVICE_UUID]: { 139 | [BLE_IMU_CALIBRATION_CHAR_UUID]: { decoder: value => value.getUint8(0) }, 140 | [BLE_IMU_SENSOR_CHAR_UUID]: { decoder: decodeSensorData }, 141 | }, 142 | }); 143 | let calibration = await calibrationP(); 144 | let listeners = []; 145 | onCalibrationChange(value => calibration = value); 146 | onSensorChange(data => { 147 | if (data) { 148 | data = { 149 | receivedAt: +new Date(), 150 | calibration, 151 | ...data, 152 | }; 153 | listeners.forEach((fn) => fn(data)); 154 | } 155 | }); 156 | return { listen: fn => listeners.push(fn) } 157 | } 158 | 159 | 160 | /** 161 | * Register a callback that is applied to each sensor message. 162 | * 163 | * @param {*} callback 164 | */ 165 | export function onSensorData(callback) { 166 | onSensorDataCallbacks.push(callback); 167 | } 168 | 169 | /* 170 | * Connection button 171 | */ 172 | 173 | function makeConnectionButton() { 174 | const button = document.createElement('button'); 175 | button.id = 'bt-connection-button'; 176 | button.innerText = 'Connect Bluetooth'; 177 | document.body.appendChild(button); 178 | return button; 179 | } 180 | 181 | const connectionButton = 182 | document.getElementById('bt-connection-button') || makeConnectionButton(); 183 | connectionButton.onclick = withConsoleErrors(connect); 184 | 185 | function hideConnectionButton() { 186 | connectionButton.style.display = 'none'; 187 | } 188 | 189 | /** 190 | * True iff BLE is available. 191 | */ 192 | export let bleAvailable = Boolean(navigator.bluetooth); 193 | 194 | if (bleAvailable) { 195 | navigator.bluetooth.getAvailability().then((flag) => { 196 | bleAvailable = flag; 197 | if (!bleAvailable) hideConnectionButton(); 198 | }); 199 | } else hideConnectionButton(); 200 | -------------------------------------------------------------------------------- /web/js/mqtt-client.js: -------------------------------------------------------------------------------- 1 | import { decodeSensorData } from './sensor-encoding.js'; 2 | import { eulerToQuat, imuQuatToEuler, quatToMatrix } from './utils.js'; 3 | 4 | /** localStorage key for connection settings. Set this with `openConnection()`. 5 | */ 6 | const STORAGE_KEY = 'imu-tools:mqtt-connection'; 7 | 8 | const connectionSettings = { 9 | hostname: '', 10 | username: '', 11 | password: '', 12 | deviceId: '', 13 | }; 14 | 15 | let client = null; 16 | 17 | /** The dat.gui object */ 18 | export let gui = null; 19 | 20 | /** Open an MQTT WS connection. Must be called before `onSensorData()`. */ 21 | export function openConnection(settings) { 22 | if (settings) { 23 | connectionSettings = settings; 24 | } 25 | startSubscription(); 26 | } 27 | 28 | /** Listeners to changes to the dat.gui connection settings. */ 29 | const datListeners = []; 30 | if (window.dat) { 31 | const container = document.getElementById('connection-gui'); 32 | gui = new dat.GUI({ autoPlace: container === null }); 33 | if (container) { 34 | container.appendChild(gui.domElement); 35 | } 36 | gui.close(); 37 | 38 | // Update connectionSettings from savedSettings 39 | function updateConnectionSettings(savedSettings) { 40 | Object.keys(savedSettings).forEach((k) => { 41 | const v = savedSettings[k]; 42 | if (typeof connectionSettings[k] === typeof v) { 43 | connectionSettings[k] = v; 44 | } 45 | }); 46 | } 47 | 48 | // Update the connection settings from the saved settings 49 | const savedSettings = 50 | JSON.parse(localStorage[STORAGE_KEY] || '{}')['remembered'] || {}; 51 | updateConnectionSettings(savedSettings); 52 | 53 | // Call the datListeners when a GUI value changes 54 | const datControllers = Object.keys(connectionSettings).map((name) => 55 | gui.add(connectionSettings, name) 56 | ); 57 | datControllers.forEach((c) => 58 | c.onFinishChange(() => datListeners.forEach((c) => c())) 59 | ); 60 | 61 | // Save to localStorage when a GUI value changes 62 | datListeners.push(() => { 63 | localStorage[STORAGE_KEY] = JSON.stringify({ 64 | remembered: connectionSettings, 65 | }); 66 | }); 67 | 68 | // Update this page's connection settings when another page writes them to 69 | // localStorage 70 | window.addEventListener('storage', (event) => { 71 | if (event.key === STORAGE_KEY) { 72 | updateConnectionSettings(JSON.parse(event.newValue).remembered); 73 | datListeners.forEach((c) => c()); 74 | datControllers.forEach((c) => c.updateDisplay()); 75 | } 76 | }); 77 | } 78 | 79 | // Display a message to the HTML element. `message` is either a string or an 80 | // object { error: messageString }. 81 | function setMqttConnectionStatus(message) { 82 | const id = 'mqtt-connection-status'; 83 | const mqttStatusElement = 84 | document.getElementById(id) || document.createElement('div'); 85 | if (!mqttStatusElement.id) { 86 | mqttStatusElement.id = id; 87 | document.body.appendChild(mqttStatusElement); 88 | } 89 | if (message.error) { 90 | message = message.error; 91 | console.error(message); 92 | mqttStatusElement.className = 'mqtt-status mqtt-error'; 93 | } else { 94 | mqttStatusElement.className = 'mqtt-status'; 95 | console.log(message); 96 | } 97 | mqttStatusElement.innerText = message.error || message || ''; 98 | } 99 | 100 | function startSubscription() { 101 | let hostname = connectionSettings.hostname; 102 | if (!hostname) return; 103 | let port = 15675; 104 | const useSSL = Boolean(hostname.match(/^wss:\/\//)); 105 | hostname = hostname.replace(/^wss?:\/\//, ''); 106 | if (hostname.match(/:/)) { 107 | port = hostname.split(/:/)[1]; 108 | hostname = hostname.split(/:/)[0]; 109 | } 110 | const clientId = 'myclientid_' + parseInt(Math.random() * 100, 10); 111 | client = new Paho.Client(hostname, Number(port), '/ws', clientId); 112 | client.onMessageArrived = onMessageArrived; 113 | client.onConnectionLost = (res) => { 114 | setMqttConnectionStatus({ 115 | error: 'MQTT connection lost: ' + res.errorMessage, 116 | }); 117 | setTimeout(startSubscription, 1000); 118 | }; 119 | 120 | const connectionOptions = { 121 | timeout: 3, 122 | useSSL, 123 | onSuccess: () => { 124 | const deviceId = connectionSettings.deviceId.trim(); 125 | let topicString = 'imu/' + (deviceId || '#'); 126 | setMqttConnectionStatus( 127 | 'Connected to mqtt://' + hostname + ':' + port 128 | ); 129 | client.subscribe(topicString, { qos: 1 }); 130 | }, 131 | onFailure: (message) => { 132 | setMqttConnectionStatus({ 133 | error: 'MQTT connection failed: ' + message.errorMessage, 134 | }); 135 | client = null; 136 | }, 137 | }; 138 | const username = connectionSettings.username.trim(); 139 | const password = connectionSettings.password.trim(); 140 | if (username) { 141 | connectionOptions.userName = username; 142 | } 143 | if (password) { 144 | connectionOptions.password = password; 145 | } 146 | client.connect(connectionOptions); 147 | } 148 | 149 | function reconnect() { 150 | if (client) { 151 | try { 152 | client.disconnect(); 153 | } catch {} 154 | client = null; 155 | } 156 | startSubscription(); 157 | } 158 | 159 | datListeners.push(reconnect); 160 | 161 | const onSensorDataCallbacks = []; 162 | const deviceStates = {}; 163 | 164 | /** Are the arguments the components of a normalized quaternion? */ 165 | const isValidQuaternion = ([q0, q1, q2, q3]) => 166 | Math.abs(q0 ** 2 + q1 ** 2 + q2 ** 2 + q3 ** 2 - 1.0) < 1e-1; 167 | 168 | const LBRACE_CODE = '{'.charCodeAt(0); 169 | 170 | function decodePayload(message) { 171 | const buffer = message.payloadBytes.buffer; 172 | if (message.payloadBytes[0] === LBRACE_CODE) 173 | return JSON.parse(message.payloadString); 174 | const ar0 = new Uint8Array(buffer); 175 | const topicLen = ar0[3]; 176 | const dv = new DataView(buffer, 4 + topicLen); 177 | const version = dv.getUint8(0); 178 | if (version != 0x01) return null; 179 | return decodeSensorData(dv); 180 | } 181 | 182 | function onMessageArrived(message) { 183 | const deviceId = message.topic.split('/').pop(); 184 | const data = decodePayload(message); 185 | if (!data) return; 186 | const { quaternion: quat } = data; 187 | 188 | // Devices on the current protocol send an initial presence message, that 189 | // doesn't include sensor data. Don't pass these on. 190 | if (!quat) return; 191 | 192 | // Discard invalid quaternions. These come from the Gravity sensor. 193 | if (!isValidQuaternion(quat)) return; 194 | 195 | const [q0, q1, q2, q3] = quat; 196 | const orientationMatrix = quatToMatrix(q3, q1, q0, q2); 197 | const receivedAt = +new Date(); 198 | 199 | // The BNO055 Euler angles are buggy. Reconstruct them from the quaternions. 200 | // const euler = quatToEuler(q3, q1, q0, q2); 201 | setDeviceData({ 202 | device: { deviceId }, 203 | deviceId, 204 | data: { 205 | receivedAt, 206 | orientationMatrix, 207 | eulerʹ: imuQuatToEuler(quat), 208 | ...data, 209 | }, 210 | }); 211 | 212 | // Simulate a second device, that constructs a new quaternion and 213 | // orientation matrix from the reconstructed euler angles. For debugging the 214 | // quat -> euler -> quat pipeline. 215 | if (false) { 216 | const [e0, e1, e2] = euler; 217 | const [q0_, q1_, q2_, q3_] = eulerToQuat(e0, e2, e1); 218 | const mʹ = quatToMatrix(q3_, q1_, q0_, q2_); 219 | setDeviceData({ 220 | receivedAt, 221 | ...data, 222 | ...{ deviceId: deviceId + '′', orientationMatrix: mʹ }, 223 | }); 224 | } 225 | 226 | function setDeviceData(data) { 227 | if (data.eulerʹ && !data.euler) { 228 | data = { euler: data.eulerʹ, ...data }; 229 | delete data.eulerʹ; 230 | } 231 | deviceStates[data.deviceId] = data; 232 | 233 | onSensorDataCallbacks.forEach((callback) => { 234 | callback(data, deviceStates); 235 | }); 236 | } 237 | } 238 | 239 | /** 240 | * Register a callback that is applied to each sensor message. 241 | * 242 | * @param {*} fn 243 | */ 244 | export function onSensorData(callback) { 245 | if (!client) { 246 | startSubscription(); 247 | } 248 | onSensorDataCallbacks.push(callback); 249 | } 250 | 251 | export function removeSensorDataCallback(callback) { 252 | const i = onSensorDataCallbacks.indexOf(callback); 253 | if (i >= 0) { 254 | onSensorDataCallbacks.splice(i, i + 1); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IMU Tools 2 | 3 | This project contains tools for sending IMU data over a WiFi or Bluetooth 4 | connection from an ESP, and consuming it in a web page. 5 | 6 | it contains these components: 7 | 8 | - A MicroPython program (in `./pyboard`) that runs on an ESP8266 or ESP32. It 9 | publishes sensor data from a connected to BNO055 IMU, a simulated IMU, to an 10 | MQTT connection, or the serial port. 11 | 12 | - Web pages (in the `web` directory) that display IMU data as graphs, charts, 13 | and 3D models using React, p5.js, Highcharts, and D3. These serve as examples 14 | of how to consume the IMU data. 15 | 16 | - A fleet management dashboard (also in the `web` directory) that displays a 17 | list of connected device, with frame rates. 18 | 19 | - Command-line tools (in the `scripts` directory, and invoked via `poetry`) 20 | to report, simulate, and measure the sample rate of IMU data, and relay it to 21 | a named port. 22 | 23 | - Additional command-line scripts for managing MicroPython/ESP development. 24 | 25 | - An npm package [imu-tools npm 26 | package](https://www.npmjs.com/package/imu-tools) that that can be included in 27 | a web page in order to subscribe to an MQTT broker that is relaying data in 28 | the **format** provided by the tools in this directory. 29 | 30 | - An experimental demonstration of rigging a Blender model to IMU output. 31 | 32 | The ESP program in this repository sends data over MQTT/WiFi. The rest of the 33 | code in this repository is also designed to work\ with 34 | , which uses Bluetooth Low Energy 35 | (BLE) connection instead. 36 | 37 | ## Modes of Operation 38 | 39 | ![Diagram of connection to local MQTT](docs/img/local-mqtt.png) Local MQTT 40 | Gateway. An ESP32 connects via WiFi to an MQTT broker running on a developer 41 | laptop. A web app, such as a React or p5.js application, can connect to this 42 | broker to read the sensor data from all the IMUs on the network. 43 | 44 | ![Diagram of two IMUs connected to a single MQTT 45 | broker](docs/img/2providers.png). This architecture MQTT Gateway allows a web 46 | page to draw information from many MCUs. 47 | 48 | ![Diagram of two computers connected to a single MQTT 49 | broker](docs/img/2consumers.png). It also allows several computers to subscribe 50 | to data from the same sensors. This is useful for an installation. 51 | 52 | ![Diagram of the MQTT broker running on its own node](docs/img/cloud-mqtt.png). 53 | Remote MQTT Gateway. The same as above, but the MQTT gateway can run on a server 54 | with greater availability than a laptop. This has the advantage that multiple 55 | laptops can all subscribe to data from a fleet of sensors. It has the 56 | disadvantage that the development machine must be on the same LAN as the MQTT 57 | broker, or it must be remote for less security and greater latency than a local 58 | deployment. 59 | 60 | ![Diagram of Bluetooth connection](docs/img/ble.png). Alternate ESP firmware, in 61 | , which publishes sensor data via 62 | BLE instead of MQTT. This permits use at lower power levels, without WIFi, and 63 | without the need for an MQTT broker. 64 | 65 | Currently, only the Chrome browser supports Web Bluetooth; and, the user has to 66 | manually initiate each BLE connection each time the page is reloaded. This is a 67 | bother during development, and when working multiple devices. As a workaround, 68 | the system can also be used in the mode: 69 | 70 | ![Diagram of Bluetooth to MQTT relay](docs/img/ble-relay.png). The BLE -> MQTT 71 | Relay is a command-line application that connects as a Central BLE device to IMU 72 | BLE Peripherals, and relays their sensor data to a (local or remote) MQTT 73 | broker. 74 | 75 | The Serial -> MQTT Gateway that publishes information from an MCU that is 76 | connected to a computer's serial port, to a local or remote MQTT broker. 77 | 78 | ## Installation 79 | 80 | 1. Clone this repo. 81 | 82 | 2. Install [poetry](https://python-poetry.org/docs/#installation). On macOS 83 | running Homebrew, you can also install it via `brew install poetry`. 84 | 85 | 3. Run `poetry install` 86 | 87 | 4. Install RabibitMQ, or find a RabbitMQ broker. Either: (1) Follow the 88 | instructions 89 | [here](https://www.notion.so/RabbitMQ-7fd3ba693d924e1e893377f719bb5f14) to 90 | install RabbitMQ on your computer; or (2) get an MQTT hostname, username, and 91 | password from your instructor, and use it in the instructions below. 92 | 93 | ## Flashing the ESP 94 | 95 | 1. Download a GENERIC image from [MicroPython Downloads](https://micropython.org/download#esp32). 96 | For example, `esp32-idf3-20191220-v1.12.bin`. 97 | 98 | 2. Flash the ESP. If you downloaded the image to 99 | `images/esp32-idf3-20191220-v1.12.bin`, then run: 100 | 101 | ```sh 102 | ./scripts/flash images/esp32-idf3-20191220-v1.12.bin 103 | ``` 104 | 105 | 3. Upload the Python source code to the ESP. In a terminal window in this 106 | directory, run: 107 | 108 | ```sh 109 | ./scripts/py-upload 110 | ``` 111 | 112 | ## Viewing the Web Examples 113 | 114 | Run `./scripts/webserver` to start a web server. 115 | 116 | displays a directory of web pages in the `web` directory 117 | directory. 118 | 119 | displays a live bar chart of sensor data. 120 | 121 | uses HighCharts to display another live 122 | graph, that automatically scales the y axis as data arrives. 123 | 124 | displays the bunny, with its orientation 125 | yolked to the IMU orientation. The model is red before the sensor is minimally 126 | calibrated, and it fades out when sensor data is not being received. 127 | 128 | ## Bluetooth 129 | 130 | is an alternative to the 131 | MicroPython program in `./pyboard`, that sends IMU data over a Bluetooth Low 132 | Energy (BLE) connection instead of MQTT. 133 | 134 | Follow the instructions in that repository to upload it to an ESP32. 135 | 136 | There are two means of using this data: 137 | 138 | 1. In a [browser that supports Bluetooth](https://caniuse.com/web-bluetooth) 139 | (e.g. Chrome or Edge; not Safari or Firefox). In Chrome, any of the demos (or 140 | a program that uses the imu-tools npm package) will include a “Connect 141 | Bluetooth" button. Clicking on this button opens the browser's Bluetooth 142 | connection dialog. The button disappears once a device has been connected; 143 | press the `b` key to bring up the connection dialog in order to connect to a 144 | second (and third, etc.) device. 145 | 2. Run the BLE->MQTT gateway. 146 | 1. Once: in the command line, run `npm install`. 147 | 2. Each time you start a development session: run `npm run ble2mqtt`. 148 | 149 | ## Command-Line Testing 150 | 151 | `poetry run sub` runs an MQTT client that subscribes to IMU messages that are 152 | sent to the MQTT broker, and relays them to the terminal. 153 | 154 | `poetry run pub` publishes an MQTT message. The message is a simulated sensor 155 | sample. 156 | 157 | `./scripts/simulate` simulates a device. It continuously publish messages from a 158 | simulated IMU until the user kills (^C) the script. (This is the same as the 159 | `--continuous` option to `poetry run pub`.) The simulated sensor readings change 160 | over time. 161 | 162 | `./scripts/simulate --help` gives a list of command-line options. Use 163 | `--device-id` to simulate a particular ID; you can use this to run multiple 164 | simulations, in different terminals, with different ids. 165 | 166 | ## MicroPython development 167 | 168 | `./scripts/py-upload` copies the code in `pyboard` to the attached ESP, and then 169 | reboots the board. 170 | 171 | `./scripts/py-repl` opens an [rshell](https://github.com/dhylands/rshell#rshell) 172 | REPL to the attached ESP. Press `⌃X` to exit the `./scripts/py-repl` command. 173 | 174 | `./scripts/serial-term` is an alternative to `./scripts/py-repl`, that uses the 175 | [GNU `screen` command](https://www.gnu.org/software/screen/) instead of 176 | `rshell`. `serial-term` connects to the board more quickly than `py-repl`. 177 | Consult the documentation for `screen` to see how to exit this command. 178 | 179 | [MicroPython Development 180 | Notes](https://paper.dropbox.com/doc/MicroPython-Development--Ai1pmnXzhBdkxZ6SuEPMTDiDAg-sAf2oqgmH5yIbmx27kZqs) 181 | contains notes on developing MicroPython on the ESP. 182 | 183 | While running a REPL on the board, press `⌃D` to reboot the board. (`⌃` is the 184 | control character. Hold it down while the `D` is pressed, and then release them 185 | both.) 186 | 187 | ## Blender 188 | 189 | As a proof of concept, IMU data can be used to control the orientation of a 190 | rigged joint in Blender. 191 | 192 | In a terminal, run: `./scripts/mqtt2pipe` 193 | 194 | In another terminal, launch Blender with the `--python blender/motion.py` option: 195 | 196 | `/Applications/Blender.app/Contents/MacOS/Blender model.blend --python blender/motion.py` 197 | 198 | Note: If the pipe buffer fills (for example, because Blender is closed), the 199 | `mqtt-sub` process will hang. You will need to force quit it (^C) and launch it 200 | again. 201 | 202 | ## Firmware Operation 203 | 204 | An ESP that is running the firmware in this directory connects to a WiFi network 205 | and an MQTT broker, and continuously publishes MQTT messages to the 206 | `imu/${device_id}` topic, where `${device_id}` is a unique device id for the 207 | ESP. (It is currently the lowercase hexadecimal representation of the device's 208 | MAC address.) The message payload is a JSON string with properties whose names 209 | are the values of the IMU sensors, fused data, and other data: accelerometer, 210 | gyroscope, magnetometer, euler, quaternion, calibration, temperature. The 211 | message also includes a "timestamp" value, which is the millisecond counter. 212 | This can be used to compute the relative time between samples. 213 | 214 | It periodically prints the sample rate to the serial port. 215 | 216 | It can optionally be configured to instead send the sensor data. 217 | 218 | The serial port format is compatible with 219 | [osteele/microbit-sensor-relay](https://github.com/osteele/microbit-sensor-relay). 220 | 221 | ## References 222 | 223 | - [Paho MQTT](https://pypi.org/project/paho-mqtt/) 224 | - [MicroPython](http://docs.micropython.org/en/latest/) 225 | - [MicroPython MQTT](https://github.com/micropython/micropython-lib/tree/master/umqtt.simple) 226 | 227 | ## Credits 228 | 229 | `BNO055.py` and `functools.py` are adapted from Radomir Dopieralski's 230 | [`deshipu/micropython-bno055`](https://github.com/deshipu/micropython-bno055). 231 | 232 | This uses [MicroPython](https://micropython.org) and Paho MQTT, and uses 233 | [rshell](https://github.com/dhylands/rshell). 234 | -------------------------------------------------------------------------------- /npm-package/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imu-tools", 3 | "version": "0.0.8", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "basic-auth": { 8 | "version": "1.1.0", 9 | "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", 10 | "integrity": "sha1-RSIe5Cn37h5QNb4/UVM/HN/SmIQ=", 11 | "dev": true 12 | }, 13 | "colors": { 14 | "version": "1.4.0", 15 | "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", 16 | "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", 17 | "dev": true 18 | }, 19 | "corser": { 20 | "version": "2.0.1", 21 | "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", 22 | "integrity": "sha1-jtolLsqrWEDc2XXOuQ2TcMgZ/4c=", 23 | "dev": true 24 | }, 25 | "ecstatic": { 26 | "version": "3.3.2", 27 | "resolved": "https://registry.npmjs.org/ecstatic/-/ecstatic-3.3.2.tgz", 28 | "integrity": "sha512-fLf9l1hnwrHI2xn9mEDT7KIi22UDqA2jaCwyCbSUJh9a1V+LEUSL/JO/6TIz/QyuBURWUHrFL5Kg2TtO1bkkog==", 29 | "dev": true, 30 | "requires": { 31 | "he": "^1.1.1", 32 | "mime": "^1.6.0", 33 | "minimist": "^1.1.0", 34 | "url-join": "^2.0.5" 35 | }, 36 | "dependencies": { 37 | "mime": { 38 | "version": "1.6.0", 39 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 40 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 41 | "dev": true 42 | } 43 | } 44 | }, 45 | "follow-redirects": { 46 | "version": "1.5.10", 47 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", 48 | "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", 49 | "dev": true, 50 | "requires": { 51 | "debug": "=3.1.0" 52 | }, 53 | "dependencies": { 54 | "debug": { 55 | "version": "3.1.0", 56 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 57 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 58 | "dev": true, 59 | "requires": { 60 | "ms": "2.0.0" 61 | } 62 | }, 63 | "ms": { 64 | "version": "2.0.0", 65 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 66 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", 67 | "dev": true 68 | } 69 | } 70 | }, 71 | "he": { 72 | "version": "1.2.0", 73 | "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", 74 | "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", 75 | "dev": true 76 | }, 77 | "http-server": { 78 | "version": "0.12.0", 79 | "resolved": "https://registry.npmjs.org/http-server/-/http-server-0.12.0.tgz", 80 | "integrity": "sha512-imGLDSTT1BZ0QG1rBFnaZ6weK5jeisUnCxZQI1cpYTdz0luPUM5e3s+WU5zRWEkiI6DQxL2p54oeKrDlzO6bRw==", 81 | "dev": true, 82 | "requires": { 83 | "basic-auth": "^1.0.3", 84 | "colors": "^1.3.3", 85 | "corser": "^2.0.1", 86 | "ecstatic": "^3.3.2", 87 | "http-proxy": "^1.17.0", 88 | "opener": "^1.5.1", 89 | "optimist": "~0.6.1", 90 | "portfinder": "^1.0.20", 91 | "secure-compare": "3.0.1", 92 | "union": "~0.5.0" 93 | }, 94 | "dependencies": { 95 | "eventemitter3": { 96 | "version": "4.0.0", 97 | "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz", 98 | "integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==", 99 | "dev": true 100 | }, 101 | "http-proxy": { 102 | "version": "1.18.0", 103 | "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz", 104 | "integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==", 105 | "dev": true, 106 | "requires": { 107 | "eventemitter3": "^4.0.0", 108 | "follow-redirects": "^1.0.0", 109 | "requires-port": "^1.0.0" 110 | } 111 | } 112 | } 113 | }, 114 | "lodash": { 115 | "version": "4.17.15", 116 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", 117 | "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", 118 | "dev": true 119 | }, 120 | "minimist": { 121 | "version": "1.2.0", 122 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", 123 | "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", 124 | "dev": true 125 | }, 126 | "mkdirp": { 127 | "version": "0.5.1", 128 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 129 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 130 | "dev": true, 131 | "requires": { 132 | "minimist": "0.0.8" 133 | }, 134 | "dependencies": { 135 | "minimist": { 136 | "version": "0.0.8", 137 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 138 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", 139 | "dev": true 140 | } 141 | } 142 | }, 143 | "ms": { 144 | "version": "2.1.2", 145 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 146 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", 147 | "dev": true 148 | }, 149 | "opener": { 150 | "version": "1.5.1", 151 | "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.1.tgz", 152 | "integrity": "sha512-goYSy5c2UXE4Ra1xixabeVh1guIX/ZV/YokJksb6q2lubWu6UbvPQ20p542/sFIll1nl8JnCyK9oBaOcCWXwvA==", 153 | "dev": true 154 | }, 155 | "optimist": { 156 | "version": "0.6.1", 157 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 158 | "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", 159 | "dev": true, 160 | "requires": { 161 | "minimist": "~0.0.1", 162 | "wordwrap": "~0.0.2" 163 | }, 164 | "dependencies": { 165 | "minimist": { 166 | "version": "0.0.10", 167 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", 168 | "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", 169 | "dev": true 170 | } 171 | } 172 | }, 173 | "portfinder": { 174 | "version": "1.0.25", 175 | "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.25.tgz", 176 | "integrity": "sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg==", 177 | "dev": true, 178 | "requires": { 179 | "async": "^2.6.2", 180 | "debug": "^3.1.1", 181 | "mkdirp": "^0.5.1" 182 | }, 183 | "dependencies": { 184 | "async": { 185 | "version": "2.6.3", 186 | "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", 187 | "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", 188 | "dev": true, 189 | "requires": { 190 | "lodash": "^4.17.14" 191 | } 192 | }, 193 | "debug": { 194 | "version": "3.2.6", 195 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", 196 | "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", 197 | "dev": true, 198 | "requires": { 199 | "ms": "^2.1.1" 200 | } 201 | } 202 | } 203 | }, 204 | "requires-port": { 205 | "version": "1.0.0", 206 | "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", 207 | "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", 208 | "dev": true 209 | }, 210 | "secure-compare": { 211 | "version": "3.0.1", 212 | "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", 213 | "integrity": "sha1-8aAymzCLIh+uN7mXTz1XjQypmeM=", 214 | "dev": true 215 | }, 216 | "union": { 217 | "version": "0.5.0", 218 | "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", 219 | "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", 220 | "dev": true, 221 | "requires": { 222 | "qs": "^6.4.0" 223 | }, 224 | "dependencies": { 225 | "qs": { 226 | "version": "6.9.1", 227 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.1.tgz", 228 | "integrity": "sha512-Cxm7/SS/y/Z3MHWSxXb8lIFqgqBowP5JMlTUFyJN88y0SGQhVmZnqFK/PeuMX9LzUyWsqqhNxIyg0jlzq946yA==", 229 | "dev": true 230 | } 231 | } 232 | }, 233 | "url-join": { 234 | "version": "2.0.5", 235 | "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz", 236 | "integrity": "sha1-WvIvGMBSoACkjXuCxenC4v7tpyg=", 237 | "dev": true 238 | }, 239 | "wordwrap": { 240 | "version": "0.0.3", 241 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", 242 | "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", 243 | "dev": true 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /scripts/read-serial-samples: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Read data from the serial port. 3 | 4 | Usage: 5 | 6 | python collect_samples.py 7 | python collect_samples.py --csv | tee data/samples.csv 8 | """ 9 | import argparse 10 | import itertools 11 | import json 12 | import os 13 | import random 14 | import struct 15 | import sys 16 | import time 17 | from collections import namedtuple 18 | from dataclasses import dataclass 19 | from glob import glob 20 | from json.decoder import JSONDecodeError 21 | from typing import Dict, Iterator, List, Optional, Union 22 | 23 | import serial 24 | from loguru import logger 25 | 26 | logger.remove() 27 | logger.add(sys.stderr, format="{time:mm:ss.SS}: {message}", level="INFO") 28 | 29 | devices: Dict[int, List[Optional[int]]] = {} 30 | 31 | 32 | @dataclass 33 | class Options: 34 | direct: bool = False 35 | include_partial_samples: bool = False 36 | gryo: bool = False 37 | verbose: bool = False 38 | 39 | 40 | class SerialDevice: 41 | """Read and write lines to a USB serial device. 42 | 43 | This assumes a single device at /dev/tty.usbmode*. 44 | """ 45 | 46 | def __init__(self, verbose=False): 47 | """If verbose is true, log sent and received lines.""" 48 | baud = os.getenv("RSHELL_BAUD", 115200) 49 | port = os.getenv("RSHELL_PORT", None) 50 | if not port: 51 | ttys = glob("/dev/tty.SLAB_USBtoUART") + glob("/dev/tty.usbmodem*") 52 | if not ttys: 53 | raise ValueError("Device not found") 54 | if len(ttys) > 1: 55 | raise ValueError("I'm not prepared to handle this many MCUs") 56 | port = ttys[0] 57 | self._tty = port 58 | self._ser = serial.Serial(self._tty, baud) 59 | self.verbose = verbose 60 | 61 | def write(self, line: str): 62 | if self.verbose: 63 | logger.info("→ {}", line) 64 | self._ser.write(f"{line}\n".encode()) 65 | self._ser.flush() 66 | 67 | def readline(self) -> Union[bytes, str]: 68 | """Read one line, where “line” is a sequence of bytes ending with b'\n'. 69 | 70 | Lines that begin with '@' are returned as bytes following the '@'. 71 | 72 | Lines that begin with '#' are optionally logged, but not returned. 73 | 74 | Other lines are returned as decoded strings. 75 | 76 | The return values does not contain the line terminator.""" 77 | while True: 78 | line = self._ser.readline() 79 | if not (line and line[0] == ord("@")): 80 | try: 81 | line = line.decode().rstrip() 82 | except UnicodeDecodeError as e: 83 | sys.stderr.write(f"Error while decoding {line}:\n{e}\n") 84 | continue 85 | if self.verbose: 86 | logger.info("← {}", line.strip()) 87 | if line and line[0] == "#": 88 | continue 89 | return line 90 | 91 | def lines(self) -> Iterator[Union[bytes, str]]: 92 | """Generate a series of lines.""" 93 | try: 94 | while True: 95 | yield self.readline() 96 | finally: 97 | self._ser.close() 98 | 99 | 100 | class IMUConfig: 101 | compass: bool = False 102 | direct: bool = False 103 | mode: str = "direct" 104 | tilt: bool = False 105 | 106 | 107 | class SerialSensorDevice(SerialDevice): 108 | _board_type: Optional[str] = None 109 | device_id: Optional[str] = None 110 | sequence_number: int = random.randint(0, 1000) * 1000 111 | 112 | def connect(self, config: IMUConfig): 113 | self._board_type = "esp" if "SLAB_USBtoUART" in self._tty else "microbit" 114 | self._ping() 115 | self._configure(config) 116 | 117 | def _command(self, cmd: str, ping=True): 118 | self.write(f":{cmd}") 119 | if ping: 120 | self._ping() 121 | 122 | def _configure(self, config: IMUConfig): 123 | if self._board_type == "esp": 124 | self._command("device_id?", ping=False) 125 | for _ in range(50): 126 | line = self.readline() 127 | if not line.startswith("!device_id="): 128 | continue 129 | self.device_id = line.split("=")[1] 130 | break 131 | if not self.device_id: 132 | sys.stderr.write("Board didn't respond to ping\n") 133 | else: 134 | mode = "direct" if config.direct else "relay" 135 | self._command(f"mode={mode}") 136 | self._command(f"compass={str(config.compass).lower()}") 137 | self._command(f"tilt={str(config.tilt).lower()}") 138 | 139 | def _ping(self): 140 | self.sequence_number = self.sequence_number + 1 141 | sequence_id = str(self.sequence_number) 142 | self._ser.reset_input_buffer() 143 | self.write(f":ping {sequence_id}") 144 | expect = f"!pong {sequence_id}" 145 | # This is a nested loop because it used to try sending the ping again, 146 | # and I might want that back 147 | for _ in range(5): 148 | for _ in range(10): 149 | line = self.readline() 150 | if line == expect: 151 | break 152 | if line == expect: 153 | break 154 | if line != expect: 155 | sys.stderr.write("Board didn't respond to ping\n") 156 | 157 | 158 | def _iter_samples(options=Options()) -> Iterator[dict]: 159 | """Generate a series of samples, as dicts.""" 160 | device = SerialSensorDevice(options.verbose) 161 | device.connect(options) 162 | 163 | for line in device.lines(): 164 | if not line: 165 | continue 166 | elif isinstance(line, bytes): 167 | sample = {"t": time.time()} 168 | line = line[1:] 169 | while line: 170 | key, line = line.split(b"=", 1) 171 | if len(line) < 4: 172 | line += b"\n" 173 | value, = struct.unpack_from("!i", line) 174 | sample[key.decode()] = value 175 | assert len(line) <= 4 or line[4] == ord(";") 176 | line = line[4:] 177 | yield sample 178 | elif line.startswith("# "): 179 | logger.info("{}", line[2:]) 180 | continue 181 | elif "{" in line: 182 | data = json.loads(line) 183 | if device.device_id: 184 | data["device_id"] = device.device_id 185 | if "accelerometer" in data: 186 | data = {k: v for k, v in zip(["ax", "ay", "az"], data["accelerometer"])} 187 | yield data 188 | elif "=" in line: 189 | sample = {"t": time.time()} 190 | for field in filter(None, line.split(";")): 191 | try: 192 | key, value = field.split("=", 1) 193 | sample[key] = float(value) if "." in value else int(value) 194 | except ValueError as e: 195 | sys.stderr.write(f"{e} while processing {field} in {line}:\n") 196 | raise e 197 | yield sample 198 | elif line[0] == "!": 199 | continue 200 | else: 201 | try: 202 | # raise Exception(f"unimplemented: parsing for {line!r}") 203 | print(f"Parse error: {line!r}", file=sys.stdout) 204 | except (UnicodeDecodeError, JSONDecodeError) as e: 205 | sys.stderr.write(f"error {e} in {line}\n") 206 | continue 207 | 208 | 209 | def iter_samples(options=Options()): 210 | """Generate a series of namedtuples.""" 211 | samples = _iter_samples(options) 212 | next(samples) # skip the first sample, in case it's from a partial line 213 | if options.count: 214 | samples = itertools.islice(samples, options.count) 215 | 216 | # create a type from the first sample 217 | sample_dict = next(samples) 218 | sampleType = namedtuple("Sample", list(sample_dict.keys())) 219 | sample = sampleType(*sample_dict.values()) 220 | yield sample 221 | 222 | previous_sample = sample 223 | samples = (sampleType(*sample.values()) for sample in samples) 224 | for sample in samples: 225 | if options.dedup and sample[1:] == previous_sample[1:]: 226 | continue 227 | previous_sample = sample 228 | yield sample 229 | 230 | 231 | def samples2csv(options): 232 | first_time = True 233 | for sample in iter_samples(options): 234 | if first_time: 235 | print(",".join(sample._fields)) 236 | first_time = False 237 | print(",".join(map(str, sample)), flush=True) 238 | 239 | 240 | def show_framerate(options): 241 | start_time, samples = time.time(), 0 242 | for _ in iter_samples(options): 243 | now = time.time() 244 | elapsed = now - start_time 245 | if elapsed > 1: 246 | logger.info("{:0.1f} samples/sec", samples / elapsed) 247 | start_time, samples = now, 0 248 | samples += 1 249 | 250 | 251 | def main(): 252 | parser = argparse.ArgumentParser() 253 | parser.add_argument("--compass", action="store_true", help="Read compass settings.") 254 | parser.add_argument( 255 | "--csv", 256 | action="store_true", 257 | default=not sys.stdout.isatty(), 258 | help="Write samples to a CSV file", 259 | ) 260 | parser.add_argument("--no-csv", action="store_false", dest="csv") 261 | parser.add_argument( 262 | "--count", action="store", type=int, help="Return the first N samples." 263 | ) 264 | parser.add_argument( 265 | "--dedup", action="store_true", default=True, help="Skip duplicate samples" 266 | ) 267 | parser.add_argument("--no-dedup", action="store_false", dest="dedup") 268 | parser.add_argument( 269 | "--direct", 270 | action="store_true", 271 | default=True, 272 | help="Read samples from the connected micro:bit's own sensors.", 273 | ) 274 | parser.add_argument( 275 | "--only", action="store", type=str, help="Print only the named field." 276 | ) 277 | parser.add_argument("--relay", action="store_false", dest="direct") 278 | parser.add_argument("--framerate", action="store_true") 279 | parser.add_argument( 280 | "--include-partial-samples", 281 | action="store_true", 282 | help="Print or relay each sample, without waiting for missing fields.", 283 | ) 284 | parser.add_argument("--tilt", action="store_true", help="Read tilt settings.") 285 | parser.add_argument("--no-tilt", action="store_false", dest="tilt") 286 | parser.add_argument("--verbose", action="store_true") 287 | args = parser.parse_args() 288 | 289 | try: 290 | if args.csv: 291 | samples2csv(args) 292 | elif args.framerate: 293 | show_framerate(args) 294 | elif args.only: 295 | for sample in iter_samples(args): 296 | ix = sample._fields.index(args.only) 297 | print(sample[ix]) 298 | else: 299 | for sample in iter_samples(args): 300 | print(sample, flush=True) 301 | except KeyboardInterrupt: 302 | pass 303 | 304 | 305 | if __name__ == "__main__": 306 | main() 307 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "dev" 3 | description = "An abstract syntax tree for Python with inference support." 4 | name = "astroid" 5 | optional = false 6 | python-versions = ">=3.5.*" 7 | version = "2.3.3" 8 | 9 | [package.dependencies] 10 | lazy-object-proxy = ">=1.4.0,<1.5.0" 11 | six = ">=1.12,<2.0" 12 | wrapt = ">=1.11.0,<1.12.0" 13 | 14 | [package.dependencies.typed-ast] 15 | python = "<3.8" 16 | version = ">=1.4.0,<1.5" 17 | 18 | [[package]] 19 | category = "main" 20 | description = "Composable command line interface toolkit" 21 | name = "click" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 24 | version = "7.1.1" 25 | 26 | [[package]] 27 | category = "main" 28 | description = "Cross-platform colored terminal text." 29 | marker = "sys_platform == \"win32\"" 30 | name = "colorama" 31 | optional = false 32 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 33 | version = "0.4.3" 34 | 35 | [[package]] 36 | category = "main" 37 | description = "ECDSA cryptographic signature library (pure python)" 38 | name = "ecdsa" 39 | optional = false 40 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 41 | version = "0.15" 42 | 43 | [package.dependencies] 44 | six = ">=1.9.0" 45 | 46 | [package.extras] 47 | gmpy = ["gmpy"] 48 | gmpy2 = ["gmpy2"] 49 | 50 | [[package]] 51 | category = "main" 52 | description = "A serial utility to communicate & flash code to Espressif ESP8266 & ESP32 chips." 53 | name = "esptool" 54 | optional = false 55 | python-versions = "*" 56 | version = "2.8" 57 | 58 | [package.dependencies] 59 | ecdsa = "*" 60 | pyaes = "*" 61 | pyserial = ">=3.0" 62 | 63 | [[package]] 64 | category = "dev" 65 | description = "A Python utility / library to sort Python imports." 66 | name = "isort" 67 | optional = false 68 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 69 | version = "4.3.21" 70 | 71 | [package.extras] 72 | pipfile = ["pipreqs", "requirementslib"] 73 | pyproject = ["toml"] 74 | requirements = ["pipreqs", "pip-api"] 75 | xdg_home = ["appdirs (>=1.4.0)"] 76 | 77 | [[package]] 78 | category = "dev" 79 | description = "A fast and thorough lazy object proxy." 80 | name = "lazy-object-proxy" 81 | optional = false 82 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 83 | version = "1.4.3" 84 | 85 | [[package]] 86 | category = "main" 87 | description = "Python logging made (stupidly) simple" 88 | name = "loguru" 89 | optional = false 90 | python-versions = ">=3.5" 91 | version = "0.4.1" 92 | 93 | [package.dependencies] 94 | colorama = ">=0.3.4" 95 | win32-setctime = ">=1.0.0" 96 | 97 | [package.extras] 98 | dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "isort (>=4.3.20)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.3b0)"] 99 | 100 | [[package]] 101 | category = "dev" 102 | description = "McCabe checker, plugin for flake8" 103 | name = "mccabe" 104 | optional = false 105 | python-versions = "*" 106 | version = "0.6.1" 107 | 108 | [[package]] 109 | category = "main" 110 | description = "MQTT version 3.1.1 client class" 111 | name = "paho-mqtt" 112 | optional = false 113 | python-versions = "*" 114 | version = "1.5.0" 115 | 116 | [package.extras] 117 | proxy = ["pysocks"] 118 | 119 | [[package]] 120 | category = "main" 121 | description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" 122 | name = "pyaes" 123 | optional = false 124 | python-versions = "*" 125 | version = "1.6.1" 126 | 127 | [[package]] 128 | category = "dev" 129 | description = "python code static checker" 130 | name = "pylint" 131 | optional = false 132 | python-versions = ">=3.5.*" 133 | version = "2.4.4" 134 | 135 | [package.dependencies] 136 | astroid = ">=2.3.0,<2.4" 137 | colorama = "*" 138 | isort = ">=4.2.5,<5" 139 | mccabe = ">=0.6,<0.7" 140 | 141 | [[package]] 142 | category = "dev" 143 | description = "pylint-common is a Pylint plugin to improve Pylint error analysis of the standard Python library" 144 | name = "pylint-common" 145 | optional = false 146 | python-versions = "*" 147 | version = "0.2.5" 148 | 149 | [package.dependencies] 150 | pylint = ">=1.0" 151 | pylint-plugin-utils = ">=0.2.5" 152 | 153 | [[package]] 154 | category = "dev" 155 | description = "Utilities and helpers for writing Pylint plugins" 156 | name = "pylint-plugin-utils" 157 | optional = false 158 | python-versions = "*" 159 | version = "0.6" 160 | 161 | [package.dependencies] 162 | pylint = ">=1.7" 163 | 164 | [[package]] 165 | category = "main" 166 | description = "A python implmementation of GNU readline." 167 | marker = "sys_platform == \"win32\"" 168 | name = "pyreadline" 169 | optional = false 170 | python-versions = "*" 171 | version = "2.1" 172 | 173 | [[package]] 174 | category = "main" 175 | description = "Python Serial Port Extension" 176 | name = "pyserial" 177 | optional = false 178 | python-versions = "*" 179 | version = "3.4" 180 | 181 | [[package]] 182 | category = "main" 183 | description = "A libudev binding" 184 | name = "pyudev" 185 | optional = false 186 | python-versions = "*" 187 | version = "0.22.0" 188 | 189 | [package.dependencies] 190 | six = "*" 191 | 192 | [[package]] 193 | category = "main" 194 | description = "A remote shell for working with MicroPython boards." 195 | name = "rshell" 196 | optional = false 197 | python-versions = "*" 198 | version = "0.0.26" 199 | 200 | [package.dependencies] 201 | pyreadline = "*" 202 | pyserial = "*" 203 | pyudev = ">=0.16" 204 | 205 | [[package]] 206 | category = "main" 207 | description = "Python 2 and 3 compatibility utilities" 208 | name = "six" 209 | optional = false 210 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 211 | version = "1.14.0" 212 | 213 | [[package]] 214 | category = "dev" 215 | description = "a fork of Python 2 and 3 ast modules with type comment support" 216 | marker = "implementation_name == \"cpython\" and python_version < \"3.8\"" 217 | name = "typed-ast" 218 | optional = false 219 | python-versions = "*" 220 | version = "1.4.1" 221 | 222 | [[package]] 223 | category = "main" 224 | description = "A small Python utility to set file creation time on Windows" 225 | marker = "sys_platform == \"win32\"" 226 | name = "win32-setctime" 227 | optional = false 228 | python-versions = ">=3.5" 229 | version = "1.0.1" 230 | 231 | [package.extras] 232 | dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] 233 | 234 | [[package]] 235 | category = "dev" 236 | description = "Module for decorators, wrappers and monkey patching." 237 | name = "wrapt" 238 | optional = false 239 | python-versions = "*" 240 | version = "1.11.2" 241 | 242 | [metadata] 243 | content-hash = "519c171337745f630c6746f77fe316f8918f014047075e87c63e7e0c0672dcb5" 244 | python-versions = "^3.7" 245 | 246 | [metadata.files] 247 | astroid = [ 248 | {file = "astroid-2.3.3-py3-none-any.whl", hash = "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"}, 249 | {file = "astroid-2.3.3.tar.gz", hash = "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a"}, 250 | ] 251 | click = [ 252 | {file = "click-7.1.1-py2.py3-none-any.whl", hash = "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"}, 253 | {file = "click-7.1.1.tar.gz", hash = "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc"}, 254 | ] 255 | colorama = [ 256 | {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, 257 | {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, 258 | ] 259 | ecdsa = [ 260 | {file = "ecdsa-0.15-py2.py3-none-any.whl", hash = "sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061"}, 261 | {file = "ecdsa-0.15.tar.gz", hash = "sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277"}, 262 | ] 263 | esptool = [ 264 | {file = "esptool-2.8.tar.gz", hash = "sha256:1e4288d9f00e55ba36809cc79c493643c623bfa036d7b019a0ebe396284bc317"}, 265 | ] 266 | isort = [ 267 | {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, 268 | {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, 269 | ] 270 | lazy-object-proxy = [ 271 | {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, 272 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, 273 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"}, 274 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"}, 275 | {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"}, 276 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"}, 277 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"}, 278 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"}, 279 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"}, 280 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"}, 281 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"}, 282 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"}, 283 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"}, 284 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"}, 285 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"}, 286 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"}, 287 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"}, 288 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"}, 289 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"}, 290 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, 291 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, 292 | ] 293 | loguru = [ 294 | {file = "loguru-0.4.1-py3-none-any.whl", hash = "sha256:074b3caa6748452c1e4f2b302093c94b65d5a4c5a4d7743636b4121e06437b0e"}, 295 | {file = "loguru-0.4.1.tar.gz", hash = "sha256:a6101fd435ac89ba5205a105a26a6ede9e4ddbb4408a6e167852efca47806d11"}, 296 | ] 297 | mccabe = [ 298 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 299 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 300 | ] 301 | paho-mqtt = [ 302 | {file = "paho-mqtt-1.5.0.tar.gz", hash = "sha256:e3d286198baaea195c8b3bc221941d25a3ab0e1507fc1779bdb7473806394be4"}, 303 | ] 304 | pyaes = [ 305 | {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, 306 | ] 307 | pylint = [ 308 | {file = "pylint-2.4.4-py3-none-any.whl", hash = "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"}, 309 | {file = "pylint-2.4.4.tar.gz", hash = "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd"}, 310 | ] 311 | pylint-common = [ 312 | {file = "pylint-common-0.2.5.tar.gz", hash = "sha256:3276b9e4db16f41cee656c78c74cfef3da383e8301e5b3b91146586ae5b53659"}, 313 | ] 314 | pylint-plugin-utils = [ 315 | {file = "pylint-plugin-utils-0.6.tar.gz", hash = "sha256:57625dcca20140f43731311cd8fd879318bf45a8b0fd17020717a8781714a25a"}, 316 | {file = "pylint_plugin_utils-0.6-py3-none-any.whl", hash = "sha256:2f30510e1c46edf268d3a195b2849bd98a1b9433229bb2ba63b8d776e1fc4d0a"}, 317 | ] 318 | pyreadline = [ 319 | {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, 320 | {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, 321 | {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, 322 | ] 323 | pyserial = [ 324 | {file = "pyserial-3.4-py2.py3-none-any.whl", hash = "sha256:e0770fadba80c31013896c7e6ef703f72e7834965954a78e71a3049488d4d7d8"}, 325 | {file = "pyserial-3.4.tar.gz", hash = "sha256:6e2d401fdee0eab996cf734e67773a0143b932772ca8b42451440cfed942c627"}, 326 | ] 327 | pyudev = [ 328 | {file = "pyudev-0.22.0.tar.gz", hash = "sha256:69bb1beb7ac52855b6d1b9fe909eefb0017f38d917cba9939602c6880035b276"}, 329 | ] 330 | rshell = [ 331 | {file = "rshell-0.0.26.tar.gz", hash = "sha256:cc447a3c9853a50585d7fdd6356922e8a89debb67932e60cf241f23fbd72db16"}, 332 | ] 333 | six = [ 334 | {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, 335 | {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, 336 | ] 337 | typed-ast = [ 338 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 339 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 340 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 341 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 342 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 343 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 344 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 345 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 346 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 347 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 348 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 349 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 350 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 351 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 352 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 353 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 354 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 355 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 356 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 357 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 358 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 359 | ] 360 | win32-setctime = [ 361 | {file = "win32_setctime-1.0.1-py3-none-any.whl", hash = "sha256:568fd636c68350bcc54755213fe01966fe0a6c90b386c0776425944a0382abef"}, 362 | {file = "win32_setctime-1.0.1.tar.gz", hash = "sha256:b47e5023ec7f0b4962950902b15bc56464a380d869f59d27dbf9ab423b23e8f9"}, 363 | ] 364 | wrapt = [ 365 | {file = "wrapt-1.11.2.tar.gz", hash = "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"}, 366 | ] 367 | --------------------------------------------------------------------------------