diff --git a/web/treadmill/treadmill.css b/web/treadmill/treadmill.css
new file mode 100644
index 00000000..99380ada
--- /dev/null
+++ b/web/treadmill/treadmill.css
@@ -0,0 +1,3 @@
+body {
+ font-family: monospace;
+}
diff --git a/web/treadmill/treadmill.html b/web/treadmill/treadmill.html
new file mode 100644
index 00000000..1ec7719c
--- /dev/null
+++ b/web/treadmill/treadmill.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/treadmill/treadmill.js b/web/treadmill/treadmill.js
new file mode 100644
index 00000000..dbac8d06
--- /dev/null
+++ b/web/treadmill/treadmill.js
@@ -0,0 +1,241 @@
+import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
+import {setupSimpleApp} from '../bumble.js';
+
+ class ScanList extends LitElement {
+ static properties = {
+ listItems: {state: true},
+ };
+
+ static styles = css`
+ table, th, td {
+ padding: 2px;
+ white-space: pre;
+ border: 1px solid black;
+ border-collapse: collapse;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.listItems = [];
+ }
+
+ render() {
+ if (this.listItems.length === 0) {
+ return '';
+ }
+ return html`
+
+
+
+ Address |
+ Address Type |
+ RSSI |
+ Data |
+ Connect |
+
+
+
+ ${this.listItems.map(i => html`
+
+ ${i['address']} |
+ ${i['address_type']} |
+ ${i['rssi']} |
+ ${i['data']} |
+ |
+
+ `)}
+
+
+ `;
+ }
+}
+customElements.define('scan-list', ScanList);
+
+
+class ConnectionInfo extends LitElement {
+ static properties = {
+ handle: {state: true},
+ role_names: {state: true},
+ self_address: {state: true},
+ peer_address: {state: true},
+ is_encrypted: {state: true},
+ };
+
+ static styles = css`
+ div {
+ border: 1px solid black;
+ border-collapse: collapse;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.handle = 0;
+ this.role = "UNKNOWN";
+ this.self_address = "00:00:00:00:00:00"
+ this.peer_address = "FF:FF:FF:FF:FF:FF"
+ this.is_encrypted = "No"
+ }
+
+ render() {
+ return html`
+
+ Connection Info
+ Handle: ${this.handle}
+ Role: ${this.role}
+ Self Address: ${this.self_address}
+ Peer Address: ${this.peer_address}
+ Is Encrypted: ${this.is_encrypted}
+
+ `;
+ }
+}
+customElements.define('connection-info', ConnectionInfo);
+
+class TreadmillValues extends LitElement {
+ static properties = {
+ listValues: {state: Array},
+ };
+
+ static styles = css`
+ table {
+ padding: 2px;
+ white-space: pre;
+ border: 1px solid black;
+ border-collapse: collapse;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.listValues = [];
+ }
+
+ addValue(value) {
+ this.listValues = [value, ...this.listValues];
+ }
+
+ render() {
+ if (this.listValues.length === 0) {
+ return '';
+ }
+ return html`
+
+
+
+ Time |
+ Value |
+
+
+
+ ${this.listValues.map(i => html`
+
+ ${i['time']} |
+ ${i['value']} |
+
+ `)}
+
+
+ `;
+ }
+}
+customElements.define('treadmill-values', TreadmillValues);
+
+class SecurityRequest extends LitElement {
+ static properties = {
+ handle: {state: true},
+ role_names: {state: true},
+ self_address: {state: true},
+ peer_address: {state: true},
+ is_encrypted: {state: true},
+ };
+
+ static styles = css`
+ div {
+ border: 1px solid black;
+ border-collapse: collapse;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.handle = 0;
+ this.role = "UNKNOWN";
+ this.self_address = "00:00:00:00:00:00"
+ this.peer_address = "FF:FF:FF:FF:FF:FF"
+ this.is_encrypted = "No"
+ }
+
+ render() {
+ return html`
+
+ Pair?
+
+
+
+ `;
+ }
+}
+customElements.define('security-request', SecurityRequest);
+
+const logOutput = document.querySelector('#log-output');
+function logToOutput(message) {
+ console.log(message);
+ logOutput.value += message + '\n';
+}
+
+// Setup the UI
+const scanList = document.querySelector('#scan-list');
+const connectionInfo = document.querySelector('#connection-info');
+const bumbleControls = document.querySelector('#bumble-controls');
+const treadmillValues = document.querySelector('#treadmill-values');
+const securityRequest = document.querySelector('#security-request');
+
+// Setup the app
+const app = await setupSimpleApp('treadmill.py', bumbleControls, logToOutput);
+app.on('scanning_updates', onScanningUpdates);
+app.on('hr_updates', onHrUpdates);
+app.on('connection_updates', onConnectionUpdates)
+app.on('on_security_request', onSecurityRequest)
+logToOutput('Click the Bluetooth button to start');
+
+function onScanningUpdates(scanResults) {
+ const items = scanResults.toJs({create_proxies : false}).map(entry => (
+ { address: entry.address, address_type: entry.address_type, rssi: entry.rssi, data: entry.data }
+ ));
+ scanResults.destroy();
+ scanList.listItems = items;
+}
+
+function onHrUpdates(hrResults) {
+ const items = hrResults.toJs({create_proxies : false})
+ treadmillValues.addValue({value: items.get('value'), time: items.get('time')})
+ hrResults.destroy();
+}
+
+function onConnectButton(address) {
+ app.do_connect(address)
+}
+
+function onSecurityRequest() {
+ securityRequest.style.display = 'block'
+}
+
+function onPairButton(value) {
+ app.do_security_request_response(value)
+ securityRequest.style.display = 'none'
+}
+
+function onConnectionUpdates(connection) {
+ const items = connection.toJs({create_proxies : false})
+ console.log(items)
+ connection.destroy();
+ scanList.style.display = 'none'
+ connectionInfo.style.display = 'block'
+ connectionInfo.handle = items.get('handle')
+ connectionInfo.role = items.get('role')
+ connectionInfo.self_address = items.get('self_address')
+ connectionInfo.peer_address = items.get('peer_address')
+ connectionInfo.is_encrypted = items.get('is_encrypted')
+}
\ No newline at end of file
diff --git a/web/treadmill/treadmill.py b/web/treadmill/treadmill.py
new file mode 100644
index 00000000..ed399d80
--- /dev/null
+++ b/web/treadmill/treadmill.py
@@ -0,0 +1,172 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from bumble.device import Device, Advertisement, Connection, Peer
+from bumble.hci import Address, HCI_Reset_Command
+from bumble.core import AdvertisingData
+from bumble.gatt import (
+ GATT_HEART_RATE_SERVICE,
+ GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
+)
+from typing import Optional, Union
+
+from bumble.utils import AsyncRunner
+import asyncio
+import datetime
+
+
+# -----------------------------------------------------------------------------
+class Treadmill:
+ peer: Optional[Peer]
+
+ class ScanEntry:
+ def __init__(self, advertisement):
+ self.address = advertisement.address.to_string(False)
+ self.address_type = (
+ 'Public',
+ 'Random',
+ 'Public Identity',
+ 'Random Identity',
+ )[advertisement.address.address_type]
+ self.rssi = advertisement.rssi
+ self.data = advertisement.data.to_string('\n')
+
+ def __init__(self, hci_source, hci_sink):
+ super().__init__()
+ random_address = Address.generate_static_address()
+ self.device = Device.with_hci('Bumbleton', random_address, hci_source, hci_sink)
+ self.scan_entries = {}
+ self.listeners = {}
+ self.peer = None
+ self.device.on('advertisement', self.on_advertisement)
+ self.device.on('connection', self.on_connection)
+
+ async def start(self):
+ print('### Starting Scanner')
+ self.scan_entries = {}
+ self.emit_scanning_update()
+ await self.device.power_on()
+ await self.device.start_scanning()
+ print('### Scanner started')
+
+ async def stop(self):
+ # TODO: replace this once a proper reset is implemented in the lib.
+ await self.device.host.send_command(HCI_Reset_Command())
+ await self.device.power_off()
+ print('### Scanner stopped')
+
+ def emit_scanning_update(self):
+ if listener := self.listeners.get('scanning_updates'):
+ listener(list(self.scan_entries.values()))
+
+ def emit_hr_update(self, value: int, time: str):
+ if listener := self.listeners.get('hr_updates'):
+ listener({"value": value, "time": time})
+
+ def emit_connection_updates(self, connection: Connection):
+ if listener := self.listeners.get('connection_updates'):
+ listener(
+ {
+ 'handle': connection.handle,
+ 'role': connection.role_name,
+ 'self_address': str(connection.self_address),
+ 'peer_address': str(connection.peer_address),
+ 'is_encrypted': "Yes" if connection.is_encrypted else "No",
+ }
+ )
+
+ def emit_on_security_request(self):
+ if listener := self.listeners.get('on_security_request'):
+ listener()
+
+ def on(self, event_name, listener):
+ self.listeners[event_name] = listener
+
+ def on_advertisement(self, advertisement: Advertisement):
+ uuids = advertisement.data.get(
+ AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ )
+ if not uuids:
+ return
+ if not GATT_HEART_RATE_SERVICE in uuids:
+ return
+
+ self.scan_entries[advertisement.address] = self.ScanEntry(advertisement)
+ self.emit_scanning_update()
+
+ @AsyncRunner.run_in_task()
+ async def on_connection(self, connection: Connection):
+ self.emit_connection_updates(connection)
+ connection.listener = self
+ connection.on('security_request', self.on_security_request)
+ connection.on('pairing_failure', self.on_pairing_failure)
+ connection.on('pairing', self.on_pairing)
+ self.peer = Peer(connection)
+ print(f'Connected to {self.peer}')
+ print("Starting service discovery...")
+ await self.peer.discover_all()
+ print("Service discovery complete!")
+
+ hr_measurement_characteristics = self.peer.get_characteristics_by_uuid(
+ uuid=GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
+ service=GATT_HEART_RATE_SERVICE,
+ )
+ hr_measurement_characteristic = hr_measurement_characteristics[0]
+ print(
+ f"HR measurement characteristic attribute: {hr_measurement_characteristic.handle}"
+ )
+
+ await hr_measurement_characteristic.subscribe(
+ lambda value: self.emit_hr_update(
+ value=int.from_bytes(value), time=str(datetime.datetime.now())
+ )
+ )
+
+ async def do_connect(self, address: str):
+ print(f'Connecting to {address}')
+ if self.device.is_scanning:
+ await self.device.stop_scanning()
+
+ await self.device.connect(address)
+
+ def on_security_request(self, _):
+ print("Received security request!")
+ self.emit_on_security_request()
+
+ def do_security_request_response(self, value: bool):
+ print(f"do_security_request_response {value}")
+ if value:
+ asyncio.create_task(self.peer.connection.pair())
+ else:
+ asyncio.create_task(
+ self.device.smp_manager.send_command(
+ self.peer.connection,
+ SMP_Pairing_Failed_Command(reason=SMP_PAIRING_NOT_SUPPORTED_ERROR),
+ )
+ )
+
+ def on_pairing_failure(self, reason):
+ self.emit_connection_updates(self.peer.connection)
+ print("Pairing failed for reason ", reason)
+
+ def on_pairing(self, keys):
+ self.emit_connection_updates(self.peer.connection)
+
+
+# -----------------------------------------------------------------------------
+def main(hci_source, hci_sink):
+ return Treadmill(hci_source, hci_sink)