From a0e5bbb23f608c9f8f7075ff71199d3736b52fe9 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 29 Oct 2023 09:04:27 -0700 Subject: [PATCH] Deployed d8517ce with MkDocs version: 1.5.3 --- 404.html | 206 ++ api/examples.html | 206 ++ api/guide.html | 206 ++ api/reference.html | 262 +- apps_and_tools/bench.html | 206 ++ apps_and_tools/console.html | 206 ++ apps_and_tools/gatt_dump.html | 206 ++ apps_and_tools/gg_bridge.html | 206 ++ apps_and_tools/hci_bridge.html | 206 ++ apps_and_tools/index.html | 206 ++ apps_and_tools/link_relay.html | 206 ++ apps_and_tools/pair.html | 206 ++ apps_and_tools/show.html | 206 ++ apps_and_tools/speaker.html | 206 ++ apps_and_tools/unbond.html | 206 ++ apps_and_tools/usb_probe.html | 206 ++ components/controller.html | 206 ++ components/gatt.html | 206 ++ components/host.html | 206 ++ components/security_manager.html | 206 ++ development/code_style.html | 206 ++ development/contributing.html | 206 ++ development/python_environments.html | 206 ++ drivers/index.html | 206 ++ drivers/realtek.html | 206 ++ examples/index.html | 222 ++ extras/android_remote_hci.html | 2341 +++++++++++++++++ extras/index.html | 2072 +++++++++++++++ getting_started.html | 206 ++ hardware/index.html | 208 +- hive/index.html | 2102 +++++++++++++++ hive/index.toml | 21 + hive/web/bumble.js | 188 ++ .../heart_rate_monitor.html | 29 + .../heart_rate_monitor/heart_rate_monitor.js | 30 + .../heart_rate_monitor/heart_rate_monitor.py | 119 + hive/web/scanner/scanner.html | 21 + hive/web/scanner/scanner.py | 72 + hive/web/speaker/logo.svg | 42 + hive/web/speaker/speaker.css | 85 + hive/web/speaker/speaker.html | 35 + hive/web/speaker/speaker.js | 221 ++ hive/web/speaker/speaker.py | 325 +++ hive/web/ui.js | 102 + index.html | 206 ++ platforms/android.html | 206 ++ platforms/index.html | 206 ++ platforms/linux.html | 206 ++ platforms/macos.html | 206 ++ platforms/windows.html | 206 ++ platforms/zephyr.html | 206 ++ sitemap.xml.gz | Bin 127 -> 127 bytes transports/android_emulator.html | 212 +- transports/file.html | 206 ++ transports/hci_socket.html | 206 ++ transports/index.html | 206 ++ transports/pty.html | 206 ++ transports/serial.html | 206 ++ transports/tcp_client.html | 206 ++ transports/tcp_server.html | 206 ++ transports/udp.html | 206 ++ transports/usb.html | 206 ++ transports/vhci.html | 206 ++ transports/ws_client.html | 206 ++ transports/ws_server.html | 206 ++ use_cases/index.html | 206 ++ use_cases/use_case_1.html | 206 ++ use_cases/use_case_2.html | 206 ++ use_cases/use_case_3.html | 206 ++ use_cases/use_case_4.html | 206 ++ use_cases/use_case_5.html | 206 ++ use_cases/use_case_6.html | 206 ++ 72 files changed, 19183 insertions(+), 32 deletions(-) create mode 100644 extras/android_remote_hci.html create mode 100644 extras/index.html create mode 100644 hive/index.html create mode 100644 hive/index.toml create mode 100644 hive/web/bumble.js create mode 100644 hive/web/heart_rate_monitor/heart_rate_monitor.html create mode 100644 hive/web/heart_rate_monitor/heart_rate_monitor.js create mode 100644 hive/web/heart_rate_monitor/heart_rate_monitor.py create mode 100644 hive/web/scanner/scanner.html create mode 100644 hive/web/scanner/scanner.py create mode 100644 hive/web/speaker/logo.svg create mode 100644 hive/web/speaker/speaker.css create mode 100644 hive/web/speaker/speaker.html create mode 100644 hive/web/speaker/speaker.js create mode 100644 hive/web/speaker/speaker.py create mode 100644 hive/web/ui.js diff --git a/404.html b/404.html index 0008a6b8..b643ae6c 100644 --- a/404.html +++ b/404.html @@ -1670,6 +1670,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/api/examples.html b/api/examples.html index c8315d3b..205de8ba 100644 --- a/api/examples.html +++ b/api/examples.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/api/guide.html b/api/guide.html index 5ed81786..10f0ca99 100644 --- a/api/guide.html +++ b/api/guide.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/api/reference.html b/api/reference.html index d87ff8f9..400e1236 100644 --- a/api/reference.html +++ b/api/reference.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + @@ -1743,9 +1949,7 @@ address[0] is the LSB of the address, address[5] is the MSB.

    Source code in bumble/hci.py -
    1737
    -1738
    -1739
    +              
    1739
     1740
     1741
     1742
    @@ -1884,7 +2088,9 @@ address[0] is the LSB of the address, address[5] is the MSB.

    1875 1876 1877 -1878
    class Address:
    +1878
    +1879
    +1880
    class Address:
         '''
         Bluetooth Address (see Bluetooth spec Vol 6, Part B - 1.3 DEVICE ADDRESS)
         NOTE: the address bytes are stored in little-endian byte order here, so
    @@ -2063,9 +2269,7 @@ the type is set to PUBLIC_DEVICE_ADDRESS.

    Source code in bumble/hci.py -
    1793
    -1794
    -1795
    +            
    1795
     1796
     1797
     1798
    @@ -2089,7 +2293,9 @@ the type is set to PUBLIC_DEVICE_ADDRESS.

    1816 1817 1818 -1819
    def __init__(
    +1819
    +1820
    +1821
    def __init__(
         self, address: Union[bytes, str], address_type: int = RANDOM_DEVICE_ADDRESS
     ):
         '''
    @@ -2141,15 +2347,15 @@ qualifier.

    Source code in bumble/hci.py -
    1855
    -1856
    -1857
    +            
    1857
     1858
     1859
     1860
     1861
     1862
    -1863
    def to_string(self, with_type_qualifier=True):
    +1863
    +1864
    +1865
    def to_string(self, with_type_qualifier=True):
         '''
         String representation of the address, MSB first, with an optional type
         qualifier.
    @@ -2185,9 +2391,7 @@ qualifier.

    Source code in bumble/hci.py -
    1909
    -1910
    -1911
    +              
    1911
     1912
     1913
     1914
    @@ -2214,7 +2418,9 @@ qualifier.

    1935 1936 1937 -1938
    class HCI_Packet:
    +1938
    +1939
    +1940
    class HCI_Packet:
         '''
         Abstract Base class for HCI packets
         '''
    @@ -2283,9 +2489,7 @@ qualifier.

    Source code in bumble/hci.py -
    1953
    -1954
    -1955
    +              
    1955
     1956
     1957
     1958
    @@ -2402,7 +2606,9 @@ qualifier.

    2069 2070 2071 -2072
    class HCI_Command(HCI_Packet):
    +2072
    +2073
    +2074
    class HCI_Command(HCI_Packet):
         '''
         See Bluetooth spec @ Vol 2, Part E - 5.4.1 HCI Command Packet
         '''
    @@ -2559,9 +2765,7 @@ qualifier.

    Source code in bumble/hci.py -
    1962
    -1963
    -1964
    +            
    1964
     1965
     1966
     1967
    @@ -2586,7 +2790,9 @@ qualifier.

    1986 1987 1988 -1989
    @staticmethod
    +1989
    +1990
    +1991
    @staticmethod
     def command(fields=(), return_parameters_fields=()):
         '''
         Decorator used to declare and register subclasses
    @@ -2643,16 +2849,16 @@ qualifier.

    Source code in bumble/hci.py -
    2118
    -2119
    -2120
    +              
    2120
     2121
     2122
     2123
     2124
     2125
     2126
    -2127
    @HCI_Command.command(
    +2127
    +2128
    +2129
    @HCI_Command.command(
         [
             ('connection_handle', 2),
             ('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
    diff --git a/apps_and_tools/bench.html b/apps_and_tools/bench.html
    index e5287c0b..07970447 100644
    --- a/apps_and_tools/bench.html
    +++ b/apps_and_tools/bench.html
    @@ -1691,6 +1691,212 @@
       
     
         
    +      
    +      
    +  
    +  
    +  
    +    
    +    
    +    
    +    
    +    
    +    
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + +
    diff --git a/apps_and_tools/console.html b/apps_and_tools/console.html index 8af09b0e..4457639f 100644 --- a/apps_and_tools/console.html +++ b/apps_and_tools/console.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/gatt_dump.html b/apps_and_tools/gatt_dump.html index 80856298..eeeb6774 100644 --- a/apps_and_tools/gatt_dump.html +++ b/apps_and_tools/gatt_dump.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/gg_bridge.html b/apps_and_tools/gg_bridge.html index 325536b5..fdf7da49 100644 --- a/apps_and_tools/gg_bridge.html +++ b/apps_and_tools/gg_bridge.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/hci_bridge.html b/apps_and_tools/hci_bridge.html index 029f5d6e..cd37caf8 100644 --- a/apps_and_tools/hci_bridge.html +++ b/apps_and_tools/hci_bridge.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/index.html b/apps_and_tools/index.html index dd0d6833..566758d6 100644 --- a/apps_and_tools/index.html +++ b/apps_and_tools/index.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/link_relay.html b/apps_and_tools/link_relay.html index 3944a567..1ddd8e39 100644 --- a/apps_and_tools/link_relay.html +++ b/apps_and_tools/link_relay.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/pair.html b/apps_and_tools/pair.html index fd1681b2..ad683cc4 100644 --- a/apps_and_tools/pair.html +++ b/apps_and_tools/pair.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/show.html b/apps_and_tools/show.html index df2d766e..62689f9b 100644 --- a/apps_and_tools/show.html +++ b/apps_and_tools/show.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/speaker.html b/apps_and_tools/speaker.html index 3670b7e8..ae24b6ca 100644 --- a/apps_and_tools/speaker.html +++ b/apps_and_tools/speaker.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/unbond.html b/apps_and_tools/unbond.html index ac422e00..3ff076cd 100644 --- a/apps_and_tools/unbond.html +++ b/apps_and_tools/unbond.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/apps_and_tools/usb_probe.html b/apps_and_tools/usb_probe.html index ccdae560..33e98bf0 100644 --- a/apps_and_tools/usb_probe.html +++ b/apps_and_tools/usb_probe.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/components/controller.html b/components/controller.html index 58947c99..15eb2449 100644 --- a/components/controller.html +++ b/components/controller.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/components/gatt.html b/components/gatt.html index 0db992ec..67f664a3 100644 --- a/components/gatt.html +++ b/components/gatt.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/components/host.html b/components/host.html index 61456f28..ff80af33 100644 --- a/components/host.html +++ b/components/host.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/components/security_manager.html b/components/security_manager.html index 04439b58..64e9c8c2 100644 --- a/components/security_manager.html +++ b/components/security_manager.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/development/code_style.html b/development/code_style.html index 3075be34..e7c967d8 100644 --- a/development/code_style.html +++ b/development/code_style.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/development/contributing.html b/development/contributing.html index 6db5933b..3285e23b 100644 --- a/development/contributing.html +++ b/development/contributing.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/development/python_environments.html b/development/python_environments.html index f9123011..dc13802c 100644 --- a/development/python_environments.html +++ b/development/python_environments.html @@ -1762,6 +1762,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/drivers/index.html b/drivers/index.html index 621fee9c..44e052dd 100644 --- a/drivers/index.html +++ b/drivers/index.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/drivers/realtek.html b/drivers/realtek.html index 242904f4..7f353099 100644 --- a/drivers/realtek.html +++ b/drivers/realtek.html @@ -1735,6 +1735,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/examples/index.html b/examples/index.html index 842a7e2c..512fca56 100644 --- a/examples/index.html +++ b/examples/index.html @@ -12,6 +12,8 @@ + + @@ -1873,6 +1875,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + @@ -2151,6 +2359,20 @@ for those characteristics at regular intervals.

    + + diff --git a/extras/android_remote_hci.html b/extras/android_remote_hci.html new file mode 100644 index 00000000..0a4f939e --- /dev/null +++ b/extras/android_remote_hci.html @@ -0,0 +1,2341 @@ + + + + + + + + + + + + + + + + + + + + + + + Android Remote HCI - Bumble + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + + + +
    + + +
    + +
    + + + + + + +
    +
    + + + +
    +
    +
    + + + + + +
    +
    +
    + + + +
    +
    +
    + + + +
    +
    +
    + + + +
    +
    + + + + +

    ANDROID REMOTE HCI APP

    +

    This application allows using an android phone's built-in Bluetooth controller with +a Bumble host stack running outside the phone (typically a development laptop or desktop). +The app runs an HCI proxy between a TCP socket on the "outside" and the Bluetooth HCI HAL +on the "inside". (See this page for a high level +description of the Android Bluetooth HCI HAL). +The HCI packets received on the TCP socket are forwarded to the phone's controller, and the +packets coming from the controller are forwarded to the TCP socket.

    +

    Building

    +

    You can build the app by running ./gradlew build (use gradlew.bat on Windows) from the RemoteHCI top level directory. +You can also build with Android Studio: open the RemoteHCI project. You can build and/or debug from there.

    +

    If the build succeeds, you can find the app APKs (debug and release) at:

    +
      +
    • [Release] app/build/outputs/apk/release/app-release-unsigned.apk
    • +
    • [Debug] app/build/outputs/apk/debug/app-debug.apk
    • +
    +

    Running

    +

    Preconditions

    +

    When the proxy starts (tapping the "Start" button in the app's main activity), it will try to +bind to the Bluetooth HAL. This requires disabling SELinux temporarily, and being the only HAL client.

    +

    Disabling SELinux

    +

    Binding to the Bluetooth HCI HAL requires certain SELinux permissions that can't simply be changed +on a device without rebuilding its system image. To bypass these restrictions, you will need +to disable SELinux on your phone (please be aware that this is global, not just for the proxy app, +so proceed with caution). +In order to disable SELinux, you need to root the phone (it may be advisable to do this on a +development phone).

    +
    +

    Disabling SELinux Temporarily

    +

    Restart adb as root: +

    $ adb root
    +

    +

    Then disable SELinux +

    $ adb shell setenforce 0
    +

    +

    Once you're done using the proxy, you can restore SELinux, if you need to, with +

    $ adb shell setenforce 1
    +

    +

    This state will also reset to the normal SELinux enforcement when you reboot.

    +
    +

    Stopping the bluetooth process

    +

    Since the Bluetooth HAL service can only accept one client, and that in normal conditions +that client is the Android's bluetooth stack, it is required to first shut down the +Android bluetooth stack process.

    +
    +

    Checking if the Bluetooth process is running

    +

    $ adb shell "ps -A | grep com.google.android.bluetooth"
    +
    +If the process is running, you will get a line like: +
    bluetooth 10759 876 17455796 136620 do_epoll_wait 0 S com.google.android.bluetooth
    +
    +If you don't, it means that the process is not running and you are clear to proceed.

    +
    +

    Simply turning Bluetooth off from the phone's settings does not ensure that the bluetooth process will exit. +If the bluetooth process is still running after toggling Bluetooth off from the settings, you may try enabling +Airplane Mode, then rebooting. The bluetooth process should, in theory, not restart after the reboot.

    +
    +

    Stopping the bluetooth process with adb

    +
    $ adb shell cmd bluetooth_manager disable
    +
    +
    +

    Starting the app

    +

    You can start the app from the Android launcher, from Android Studio, or with adb

    +

    Launching from the launcher

    +

    Just tap the app icon on the launcher, check the TCP port that is configured, and tap +the "Start" button.

    +

    Launching with adb

    +

    Using the am command, you can start the activity, and pass it arguments so that you can +automatically start the proxy, and/or set the port number.

    +
    +

    Launching from adb with auto-start

    +
    $ adb shell am start -n com.github.google.bumble.remotehci/.MainActivity --ez autostart true
    +
    +
    +
    +

    Launching from adb with auto-start and a port

    +

    In this example, we auto-start the proxy upon launch, with the port set to 9995 +

    $ adb shell am start -n com.github.google.bumble.remotehci/.MainActivity --ez autostart true --ei port 9995
    +

    +
    +

    Selecting a TCP port

    +

    The RemoteHCI app's main activity has a "TCP Port" setting where you can change the port on +which the proxy is accepting connections. If the default value isn't suitable, you can +change it there (you can also use the special value 0 to let the OS assign a port number for you).

    +

    Connecting to the proxy

    +

    To connect the Bumble stack to the proxy, you need to be able to reach the phone's network +stack. This can be done over the phone's WiFi connection, or, alternatively, using an adb +TCP forward (which should be faster than over WiFi).

    +
    +

    Forwarding TCP with adb

    +

    To connect to the proxy via an adb TCP forward, use: +

    $ adb forward tcp:<outside-port> tcp:<inside-port>
    +
    +Where <outside-port> is the port number for a listening socket on your laptop or +desktop machine, and is the TCP port selected in the app's user interface. +Those two ports may be the same, of course. +For example, with the default TCP port 9993: +
    $ adb forward tcp:9993 tcp:9993
    +

    +
    +

    Once you've ensured that you can reach the proxy's TCP port on the phone, either directly or +via an adb forward, you can then use it as a Bumble transport, using the transport name: +tcp-client:<host>:<port> syntax.

    +
    +

    Connecting a Bumble client

    +

    Connecting the bumble-controller-info app to the phone's controller. +Assuming you have set up an adb forward on port 9993: +

    $ bumble-controller-info tcp-client:localhost:9993
    +

    +

    Or over WiFi with, in this example, the IP address of the phone being 192.168.86.27 +

    $ bumble-controller-info tcp-client:192.168.86.27:9993
    +

    +
    + + + + + + +
    +
    + + +
    + +
    + + + + +
    +
    +
    +
    + + + + + + + + + + \ No newline at end of file diff --git a/extras/index.html b/extras/index.html new file mode 100644 index 00000000..18390dd6 --- /dev/null +++ b/extras/index.html @@ -0,0 +1,2072 @@ + + + + + + + + + + + + + + + + + + + + + + + Overview - Bumble + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + + + +
    + + +
    + +
    + + + + + + +
    +
    + + + +
    +
    +
    + + + + + +
    +
    +
    + + + +
    +
    +
    + + + +
    +
    +
    + + + +
    +
    + + + + +

    EXTRAS

    +

    A collection of add-ons, apps and tools, to the Bumble project.

    +

    Android Remote HCI

    +

    Allows using an Android phone's built-in Bluetooth controller with a Bumble +stack running on a development machine. +See Android Remote HCI for details.

    + + + + + + +
    +
    + + +
    + +
    + + + + +
    +
    +
    +
    + + + + + + + + + + \ No newline at end of file diff --git a/getting_started.html b/getting_started.html index 94036242..7d74dc22 100644 --- a/getting_started.html +++ b/getting_started.html @@ -1689,6 +1689,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/hardware/index.html b/hardware/index.html index 1f085a71..92759c45 100644 --- a/hardware/index.html +++ b/hardware/index.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + @@ -1727,7 +1933,7 @@

    HARDWARE

    The Bumble Host connects to a controller over an HCI Transport. To use a hardware controller attached to the host on which the host application is running, the transport is typically either HCI over UART or HCI over USB. -On Linux, the VHCI Transport can be used to communicate with any controller hardware managed by the operating system. Alternatively, a remote controller (a phyiscal controller attached to a remote host) can be used by connecting one of the networked transports (such as the TCP Client transport, the TCP Server transport or the UDP Transport) to an HCI Bridge bridging the network transport to a physical controller on a remote host.

    +On Linux, the VHCI Transport can be used to communicate with any controller hardware managed by the operating system. Alternatively, a remote controller (a phyiscal controller attached to a remote host) can be used by connecting one of the networked transports (such as the TCP Client transport, the TCP Server transport or the UDP Transport) to an HCI Bridge bridging the network transport to a physical controller on a remote host.

    In theory, any controller that is compliant with the HCI over UART or HCI over USB protocols can be used.

    HCI over USB is very common, implemented by a number of commercial Bluetooth dongles.

    It is also possible to use an embedded development board, running a specialized application, such as the HCI UART and HCI USB demo applications from the Zephyr project, or the blehci application from mynewt/nimble

    diff --git a/hive/index.html b/hive/index.html new file mode 100644 index 00000000..0a1128cc --- /dev/null +++ b/hive/index.html @@ -0,0 +1,2102 @@ + + + + + + + + + + + + + + + + + + + + + + + Overview - Bumble + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + + + +
    + + +
    + +
    + + + + + + +
    +
    + + + +
    +
    +
    + + + + + +
    +
    +
    + + + +
    +
    +
    + + + +
    +
    +
    + + + +
    +
    + + + + +

    HIVE

    +

    Welcome to the Bumble Hive. +This is a collection of apps and virtual devices that can run entirely in a browser page. +The code for the apps and devices, as well as the Bumble runtime code, runs via Pyodide. +Pyodide is a Python distribution for the browser and Node.js based on WebAssembly.

    +

    The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical +Bluetooth controller.

    +

    The apps and devices in the hive can be accessed by following the links below. Each +page has a settings button that may be used to configure the WebSocket URL to use for +the virutal HCI connection. This will typically be the WebSocket URL for a netsim +daemon. +There is also a TOML index that can be used by tools to know at which URL to access +each of the apps and devices, as well as their names and short desciptions.

    +

    Applications

    +
      +
    • Scanner - Scans for BLE devices.
    • +
    +

    Virtual Devices

    + + + + + + + +
    +
    + + +
    + +
    + + + + +
    +
    +
    +
    + + + + + + + + + + \ No newline at end of file diff --git a/hive/index.toml b/hive/index.toml new file mode 100644 index 00000000..5b187e39 --- /dev/null +++ b/hive/index.toml @@ -0,0 +1,21 @@ +version = "1.0.0" +base_url = "https://google.github.io/bumble/hive/web" +default_hci_query_param = "hci" + +[[index]] +name = "speaker" +description = "Bumble Virtual Speaker" +type = "Device" +url = "speaker/speaker.html" + +[[index]] +name = "scanner" +description = "Simple Scanner Application" +type = "Application" +url = "scanner/scanner.html" + +[[index]] +name = "heart-rate-monitor" +description = "Virtual Heart Rate Monitor" +type = "Device" +url = "heart_rate_monitor/heart_rate_monitor.html" diff --git a/hive/web/bumble.js b/hive/web/bumble.js new file mode 100644 index 00000000..cb807eb7 --- /dev/null +++ b/hive/web/bumble.js @@ -0,0 +1,188 @@ +function bufferToHex(buffer) { + return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join(''); +} + +class PacketSource { + constructor(pyodide) { + this.parser = pyodide.runPython(` + from bumble.transport.common import PacketParser + class ProxiedPacketParser(PacketParser): + def feed_data(self, js_data): + super().feed_data(bytes(js_data.to_py())) + ProxiedPacketParser() + `); + } + + set_packet_sink(sink) { + this.parser.set_packet_sink(sink); + } + + data_received(data) { + //console.log(`HCI[controller->host]: ${bufferToHex(data)}`); + this.parser.feed_data(data); + } +} + +class PacketSink { + on_packet(packet) { + if (!this.writer) { + return; + } + const buffer = packet.toJs({create_proxies : false}); + packet.destroy(); + //console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`); + // TODO: create an async queue here instead of blindly calling write without awaiting + this.writer(buffer); + } +} + +class LogEvent extends Event { + constructor(message) { + super('log'); + this.message = message; + } +} + +export class Bumble extends EventTarget { + constructor(pyodide) { + super(); + this.pyodide = pyodide; + } + + async loadRuntime(bumblePackage) { + // Load pyodide if it isn't provided. + if (this.pyodide === undefined) { + this.log('Loading Pyodide'); + this.pyodide = await loadPyodide(); + } + + // Load the Bumble module + bumblePackage ||= 'bumble'; + console.log('Installing micropip'); + this.log(`Installing ${bumblePackage}`) + await this.pyodide.loadPackage('micropip'); + await this.pyodide.runPythonAsync(` + import micropip + await micropip.install('${bumblePackage}') + package_list = micropip.list() + print(package_list) + `) + + // Mount a filesystem so that we can persist data like the Key Store + let mountDir = '/bumble'; + this.pyodide.FS.mkdir(mountDir); + this.pyodide.FS.mount(this.pyodide.FS.filesystems.IDBFS, { root: '.' }, mountDir); + + // Sync previously persisted filesystem data into memory + await new Promise(resolve => { + this.pyodide.FS.syncfs(true, () => { + console.log('FS synced in'); + resolve(); + }); + }) + + // Setup the HCI source and sink + this.packetSource = new PacketSource(this.pyodide); + this.packetSink = new PacketSink(); + } + + log(message) { + this.dispatchEvent(new LogEvent(message)); + } + + async connectWebSocketTransport(hciWsUrl) { + return new Promise((resolve, reject) => { + let resolved = false; + + let ws = new WebSocket(hciWsUrl); + ws.binaryType = 'arraybuffer'; + + ws.onopen = () => { + this.log('WebSocket open'); + resolve(); + resolved = true; + } + + ws.onclose = () => { + this.log('WebSocket close'); + if (!resolved) { + reject(`Failed to connect to ${hciWsUrl}`); + } + } + + ws.onmessage = (event) => { + this.packetSource.data_received(event.data); + } + + this.packetSink.writer = (packet) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(packet); + } + } + this.closeTransport = async () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + } + }) + } + + async loadApp(appUrl) { + this.log('Loading app'); + const script = await (await fetch(appUrl)).text(); + await this.pyodide.runPythonAsync(script); + const pythonMain = this.pyodide.globals.get('main'); + const app = await pythonMain(this.packetSource, this.packetSink); + if (app.on) { + app.on('key_store_update', this.onKeystoreUpdate.bind(this)); + } + this.log('App is ready!'); + return app; + } + + onKeystoreUpdate() { + // Sync the FS + this.pyodide.FS.syncfs(() => { + console.log('FS synced out'); + }); + } +} + +export async function setupSimpleApp(appUrl, bumbleControls, log) { + // Load Bumble + log('Loading Bumble'); + const bumble = new Bumble(); + bumble.addEventListener('log', (event) => { + log(event.message); + }) + const params = (new URL(document.location)).searchParams; + await bumble.loadRuntime(params.get('package')); + + log('Bumble is ready!') + const app = await bumble.loadApp(appUrl); + + bumbleControls.connector = async (hciWsUrl) => { + try { + // Connect the WebSocket HCI transport + await bumble.connectWebSocketTransport(hciWsUrl); + + // Start the app + await app.start(); + + return true; + } catch (err) { + log(err); + return false; + } + } + bumbleControls.stopper = async () => { + // Stop the app + await app.stop(); + + // Close the HCI transport + await bumble.closeTransport(); + } + bumbleControls.onBumbleLoaded(); + + return app; +} \ No newline at end of file diff --git a/hive/web/heart_rate_monitor/heart_rate_monitor.html b/hive/web/heart_rate_monitor/heart_rate_monitor.html new file mode 100644 index 00000000..f44470fd --- /dev/null +++ b/hive/web/heart_rate_monitor/heart_rate_monitor.html @@ -0,0 +1,29 @@ + + + + + + + + + + + +
    + + cardiology + + 60 +
    + + +
    +
    + + diff --git a/hive/web/heart_rate_monitor/heart_rate_monitor.js b/hive/web/heart_rate_monitor/heart_rate_monitor.js new file mode 100644 index 00000000..468e728c --- /dev/null +++ b/hive/web/heart_rate_monitor/heart_rate_monitor.js @@ -0,0 +1,30 @@ +import {setupSimpleApp} from '../bumble.js'; + +const logOutput = document.querySelector('#log-output'); +function logToOutput(message) { + console.log(message); + logOutput.value += message + '\n'; +} + +let heartRate = 60; +const heartRateText = document.querySelector('#hr-value') + +function setHeartRate(newHeartRate) { + heartRate = newHeartRate; + heartRateText.innerHTML = heartRate; + app.set_heart_rate(heartRate); +} + +// Setup the UI +const bumbleControls = document.querySelector('#bumble-controls'); +document.querySelector('#hr-up-button').addEventListener('click', () => { + setHeartRate(heartRate + 1); +}) +document.querySelector('#hr-down-button').addEventListener('click', () => { + setHeartRate(heartRate - 1); +}) + +// Setup the app +const app = await setupSimpleApp('heart_rate_monitor.py', bumbleControls, logToOutput); +logToOutput('Click the Bluetooth button to start'); + diff --git a/hive/web/heart_rate_monitor/heart_rate_monitor.py b/hive/web/heart_rate_monitor/heart_rate_monitor.py new file mode 100644 index 00000000..4a843b46 --- /dev/null +++ b/hive/web/heart_rate_monitor/heart_rate_monitor.py @@ -0,0 +1,119 @@ +# 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 +# ----------------------------------------------------------------------------- +import struct + +from bumble.core import AdvertisingData +from bumble.device import Device +from bumble.hci import HCI_Reset_Command +from bumble.profiles.device_information_service import DeviceInformationService +from bumble.profiles.heart_rate_service import HeartRateService +from bumble.utils import AsyncRunner + + +# ----------------------------------------------------------------------------- +class HeartRateMonitor: + def __init__(self, hci_source, hci_sink): + self.heart_rate = 60 + + self.device = Device.with_hci( + 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink + ) + + device_information_service = DeviceInformationService( + manufacturer_name='ACME', + model_number='HR-102', + serial_number='7654321', + hardware_revision='1.1.3', + software_revision='2.5.6', + system_id=(0x123456, 0x8877665544), + ) + + self.heart_rate_service = HeartRateService( + read_heart_rate_measurement=lambda _: HeartRateService.HeartRateMeasurement( + heart_rate=self.heart_rate, + sensor_contact_detected=True, + ), + body_sensor_location=HeartRateService.BodySensorLocation.WRIST, + reset_energy_expended=self.reset_energy_expended, + ) + + # Notify subscribers of the current value as soon as they subscribe + @self.heart_rate_service.heart_rate_measurement_characteristic.on( + 'subscription' + ) + def on_subscription(_, notify_enabled, indicate_enabled): + if notify_enabled or indicate_enabled: + self.notify_heart_rate() + + self.device.add_services([device_information_service, self.heart_rate_service]) + + self.device.advertising_data = bytes( + AdvertisingData( + [ + ( + AdvertisingData.FLAGS, + bytes( + [ + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG + ] + ), + ), + ( + AdvertisingData.COMPLETE_LOCAL_NAME, + bytes('Bumble Heart', 'utf-8'), + ), + ( + AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + bytes(self.heart_rate_service.uuid), + ), + (AdvertisingData.APPEARANCE, struct.pack(' + + + + + + + + + + + + + +
    +
    + + + diff --git a/hive/web/scanner/scanner.py b/hive/web/scanner/scanner.py new file mode 100644 index 00000000..e498f7a1 --- /dev/null +++ b/hive/web/scanner/scanner.py @@ -0,0 +1,72 @@ +# 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 +from bumble.hci import HCI_Reset_Command + + +# ----------------------------------------------------------------------------- +class Scanner: + 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__() + self.device = Device.with_hci( + 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink + ) + self.scan_entries = {} + self.listeners = {} + self.device.on('advertisement', self.on_advertisement) + + async def start(self): + print('### Starting Scanner') + self.scan_entries = {} + self.emit_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_update(self): + if listener := self.listeners.get('update'): + listener(list(self.scan_entries.values())) + + def on(self, event_name, listener): + self.listeners[event_name] = listener + + def on_advertisement(self, advertisement): + self.scan_entries[advertisement.address] = self.ScanEntry(advertisement) + self.emit_update() + +# ----------------------------------------------------------------------------- +def main(hci_source, hci_sink): + return Scanner(hci_source, hci_sink) diff --git a/hive/web/speaker/logo.svg b/hive/web/speaker/logo.svg new file mode 100644 index 00000000..70ef7a90 --- /dev/null +++ b/hive/web/speaker/logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hive/web/speaker/speaker.css b/hive/web/speaker/speaker.css new file mode 100644 index 00000000..95860548 --- /dev/null +++ b/hive/web/speaker/speaker.css @@ -0,0 +1,85 @@ +body, h1, h2, h3, h4, h5, h6 { + font-family: sans-serif; +} + +#controlsDiv { + margin: 6px; +} + +#errorText { + background-color: rgb(239, 89, 75); + border: none; + border-radius: 4px; + padding: 8px; + display: none; + margin: 4px; +} + +#progressText { + background-color: rgb(179, 208, 146); + border: none; + border-radius: 4px; + padding: 8px; + display: none; + margin: 4px; +} + +#startButton { + padding: 4px; + margin: 6px; +} + +#fftCanvas { + border-radius: 16px; + margin: 6px; +} + +#bandwidthCanvas { + border: grey; + border-style: solid; + border-radius: 8px; + margin: 6px; +} + +#streamStateText { + background-color: rgb(93, 165, 93); + border: none; + border-radius: 8px; + padding: 10px 20px; + display: inline-block; + margin: 6px; +} + +#connectionStateText { + background-color: rgb(112, 146, 206); + border: none; + border-radius: 8px; + padding: 10px 20px; + display: inline-block; + margin: 6px; +} + +#propertiesTable { + border: grey; + border-style: solid; + border-radius: 4px; + padding: 4px; + margin: 6px; + margin-left: 0px; +} + +th, td { + padding-left: 6px; + padding-right: 6px; +} + +.properties td:nth-child(even) { + background-color: #D6EEEE; + font-family: monospace; +} + +.properties td:nth-child(odd) { + font-weight: bold; +} + +.properties tr td:nth-child(2) { width: 150px; } \ No newline at end of file diff --git a/hive/web/speaker/speaker.html b/hive/web/speaker/speaker.html new file mode 100644 index 00000000..750df9d0 --- /dev/null +++ b/hive/web/speaker/speaker.html @@ -0,0 +1,35 @@ + + + + Bumble Speaker + + + + + + +

    Bumble Virtual Speaker

    +
    + + + +
    + + + + +
    Codec
    Packets
    Bytes
    +
    + Bandwidth Graph +
    + IDLE + NOT CONNECTED +
    + + +
    + Audio Frequencies Animation + +
    + + \ No newline at end of file diff --git a/hive/web/speaker/speaker.js b/hive/web/speaker/speaker.js new file mode 100644 index 00000000..12189a47 --- /dev/null +++ b/hive/web/speaker/speaker.js @@ -0,0 +1,221 @@ +import {setupSimpleApp} from '../bumble.js'; + +(function () { + 'use strict'; + + let codecText; + let packetsReceivedText; + let bytesReceivedText; + let streamStateText; + let connectionStateText; + let audioOnButton; + let mediaSource; + let sourceBuffer; + let audioElement; + let audioContext; + let audioAnalyzer; + let audioFrequencyBinCount; + let audioFrequencyData; + let packetsReceived = 0; + let bytesReceived = 0; + let audioState = 'stopped'; + let streamState = 'IDLE'; + let fftCanvas; + let fftCanvasContext; + let bandwidthCanvas; + let bandwidthCanvasContext; + let bandwidthBinCount; + let bandwidthBins = []; + + const FFT_WIDTH = 800; + const FFT_HEIGHT = 256; + const BANDWIDTH_WIDTH = 500; + const BANDWIDTH_HEIGHT = 100; + + + function init() { + initUI(); + initMediaSource(); + initAudioElement(); + initAnalyzer(); + initBumble(); + } + + function initUI() { + audioOnButton = document.getElementById('audioOnButton'); + codecText = document.getElementById('codecText'); + packetsReceivedText = document.getElementById('packetsReceivedText'); + bytesReceivedText = document.getElementById('bytesReceivedText'); + streamStateText = document.getElementById('streamStateText'); + connectionStateText = document.getElementById('connectionStateText'); + + audioOnButton.onclick = startAudio; + + codecText.innerText = 'AAC'; + + requestAnimationFrame(onAnimationFrame); + } + + function initMediaSource() { + mediaSource = new MediaSource(); + mediaSource.onsourceopen = onMediaSourceOpen; + mediaSource.onsourceclose = onMediaSourceClose; + mediaSource.onsourceended = onMediaSourceEnd; + } + + function initAudioElement() { + audioElement = document.getElementById('audio'); + audioElement.src = URL.createObjectURL(mediaSource); + // audioElement.controls = true; + } + + function initAnalyzer() { + fftCanvas = document.getElementById('fftCanvas'); + fftCanvas.width = FFT_WIDTH + fftCanvas.height = FFT_HEIGHT + fftCanvasContext = fftCanvas.getContext('2d'); + fftCanvasContext.fillStyle = 'rgb(0, 0, 0)'; + fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); + + bandwidthCanvas = document.getElementById('bandwidthCanvas'); + bandwidthCanvas.width = BANDWIDTH_WIDTH + bandwidthCanvas.height = BANDWIDTH_HEIGHT + bandwidthCanvasContext = bandwidthCanvas.getContext('2d'); + bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)'; + bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT); + } + + async function initBumble() { + const bumbleControls = document.querySelector('#bumble-controls'); + const app = await setupSimpleApp('speaker.py', bumbleControls, console.log); + app.on('start', onStart); + app.on('stop', onStop); + app.on('suspend', onSuspend); + app.on('connection', onConnection); + app.on('disconnection', onDisconnection); + app.on('audio', onAudio); + } + + function startAnalyzer() { + // FFT + if (audioElement.captureStream !== undefined) { + audioContext = new AudioContext(); + audioAnalyzer = audioContext.createAnalyser(); + audioAnalyzer.fftSize = 128; + audioFrequencyBinCount = audioAnalyzer.frequencyBinCount; + audioFrequencyData = new Uint8Array(audioFrequencyBinCount); + const stream = audioElement.captureStream(); + const source = audioContext.createMediaStreamSource(stream); + source.connect(audioAnalyzer); + } + + // Bandwidth + bandwidthBinCount = BANDWIDTH_WIDTH / 2; + bandwidthBins = []; + } + + function setStreamState(state) { + streamState = state; + streamStateText.innerText = streamState; + } + + function onAnimationFrame() { + // FFT + if (audioAnalyzer !== undefined) { + audioAnalyzer.getByteFrequencyData(audioFrequencyData); + fftCanvasContext.fillStyle = 'rgb(0, 0, 0)'; + fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); + const barCount = audioFrequencyBinCount; + const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1; + for (let bar = 0; bar < barCount; bar++) { + const barHeight = audioFrequencyData[bar]; + fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`; + fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight); + } + } + + // Bandwidth + bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)'; + bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT); + bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`; + for (let t = 0; t < bandwidthBins.length; t++) { + const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT; + bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight); + } + + // Display again at the next frame + requestAnimationFrame(onAnimationFrame); + } + + function onMediaSourceOpen() { + console.log(this.readyState); + sourceBuffer = mediaSource.addSourceBuffer('audio/aac'); + } + + function onMediaSourceClose() { + console.log(this.readyState); + } + + function onMediaSourceEnd() { + console.log(this.readyState); + } + + async function startAudio() { + try { + console.log('starting audio...'); + audioOnButton.disabled = true; + audioState = 'starting'; + await audioElement.play(); + console.log('audio started'); + audioState = 'playing'; + startAnalyzer(); + } catch (error) { + console.error(`play failed: ${error}`); + audioState = 'stopped'; + audioOnButton.disabled = false; + } + } + + function onStart() { + setStreamState('STARTED'); + } + + function onStop() { + setStreamState('STOPPED'); + } + + function onSuspend() { + setStreamState('SUSPENDED'); + } + + function onConnection(params) { + connectionStateText.innerText = `CONNECTED: ${params.get('peer_name')} (${params.get('peer_address')})`; + } + + function onDisconnection(params) { + connectionStateText.innerText = 'DISCONNECTED'; + } + + function onAudio(python_packet) { + const packet = python_packet.toJs({create_proxies : false}); + python_packet.destroy(); + if (audioState != 'stopped') { + // Queue the audio packet. + sourceBuffer.appendBuffer(packet); + } + + packetsReceived += 1; + packetsReceivedText.innerText = packetsReceived; + bytesReceived += packet.byteLength; + bytesReceivedText.innerText = bytesReceived; + + bandwidthBins[bandwidthBins.length] = packet.byteLength; + if (bandwidthBins.length > bandwidthBinCount) { + bandwidthBins.shift(); + } + } + + window.onload = (event) => { + init(); + } +}()); \ No newline at end of file diff --git a/hive/web/speaker/speaker.py b/hive/web/speaker/speaker.py new file mode 100644 index 00000000..4157981f --- /dev/null +++ b/hive/web/speaker/speaker.py @@ -0,0 +1,325 @@ +# Copyright 2021-2023 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 __future__ import annotations +import enum +import logging +from typing import Dict, List + +from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError +from bumble.device import Device, DeviceConfiguration +from bumble.pairing import PairingConfig +from bumble.sdp import ServiceAttribute +from bumble.avdtp import ( + AVDTP_AUDIO_MEDIA_TYPE, + Listener, + MediaCodecCapabilities, + MediaPacket, + Protocol, +) +from bumble.a2dp import ( + make_audio_sink_service_sdp_records, + MPEG_2_AAC_LC_OBJECT_TYPE, + A2DP_SBC_CODEC_TYPE, + A2DP_MPEG_2_4_AAC_CODEC_TYPE, + SBC_MONO_CHANNEL_MODE, + SBC_DUAL_CHANNEL_MODE, + SBC_SNR_ALLOCATION_METHOD, + SBC_LOUDNESS_ALLOCATION_METHOD, + SBC_STEREO_CHANNEL_MODE, + SBC_JOINT_STEREO_CHANNEL_MODE, + SbcMediaCodecInformation, + AacMediaCodecInformation, +) +from bumble.utils import AsyncRunner +from bumble.codecs import AacAudioRtpPacket +from bumble.hci import HCI_Reset_Command + + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +class AudioExtractor: + @staticmethod + def create(codec: str): + if codec == 'aac': + return AacAudioExtractor() + if codec == 'sbc': + return SbcAudioExtractor() + + def extract_audio(self, packet: MediaPacket) -> bytes: + raise NotImplementedError() + + +# ----------------------------------------------------------------------------- +class AacAudioExtractor: + def extract_audio(self, packet: MediaPacket) -> bytes: + return AacAudioRtpPacket(packet.payload).to_adts() + + +# ----------------------------------------------------------------------------- +class SbcAudioExtractor: + def extract_audio(self, packet: MediaPacket) -> bytes: + # header = packet.payload[0] + # fragmented = header >> 7 + # start = (header >> 6) & 0x01 + # last = (header >> 5) & 0x01 + # number_of_frames = header & 0x0F + + # TODO: support fragmented payloads + return packet.payload[1:] + + +# ----------------------------------------------------------------------------- +class Speaker: + class StreamState(enum.Enum): + IDLE = 0 + STOPPED = 1 + STARTED = 2 + SUSPENDED = 3 + + def __init__(self, hci_source, hci_sink, codec): + self.hci_source = hci_source + self.hci_sink = hci_sink + self.js_listeners = {} + self.codec = codec + self.device = None + self.connection = None + self.avdtp_listener = None + self.packets_received = 0 + self.bytes_received = 0 + self.stream_state = Speaker.StreamState.IDLE + self.audio_extractor = AudioExtractor.create(codec) + + def sdp_records(self) -> Dict[int, List[ServiceAttribute]]: + service_record_handle = 0x00010001 + return { + service_record_handle: make_audio_sink_service_sdp_records( + service_record_handle + ) + } + + def codec_capabilities(self) -> MediaCodecCapabilities: + if self.codec == 'aac': + return self.aac_codec_capabilities() + + if self.codec == 'sbc': + return self.sbc_codec_capabilities() + + raise RuntimeError('unsupported codec') + + def aac_codec_capabilities(self) -> MediaCodecCapabilities: + return MediaCodecCapabilities( + media_type=AVDTP_AUDIO_MEDIA_TYPE, + media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, + media_codec_information=AacMediaCodecInformation.from_lists( + object_types=[MPEG_2_AAC_LC_OBJECT_TYPE], + sampling_frequencies=[48000, 44100], + channels=[1, 2], + vbr=1, + bitrate=256000, + ), + ) + + def sbc_codec_capabilities(self) -> MediaCodecCapabilities: + return MediaCodecCapabilities( + media_type=AVDTP_AUDIO_MEDIA_TYPE, + media_codec_type=A2DP_SBC_CODEC_TYPE, + media_codec_information=SbcMediaCodecInformation.from_lists( + sampling_frequencies=[48000, 44100, 32000, 16000], + channel_modes=[ + SBC_MONO_CHANNEL_MODE, + SBC_DUAL_CHANNEL_MODE, + SBC_STEREO_CHANNEL_MODE, + SBC_JOINT_STEREO_CHANNEL_MODE, + ], + block_lengths=[4, 8, 12, 16], + subbands=[4, 8], + allocation_methods=[ + SBC_LOUDNESS_ALLOCATION_METHOD, + SBC_SNR_ALLOCATION_METHOD, + ], + minimum_bitpool_value=2, + maximum_bitpool_value=53, + ), + ) + + def on_key_store_update(self): + print("Key Store updated") + self.emit('key_store_update') + + def on_bluetooth_connection(self, connection): + print(f'Connection: {connection}') + self.connection = connection + connection.on('disconnection', self.on_bluetooth_disconnection) + peer_name = '' if connection.peer_name is None else connection.peer_name + peer_address = connection.peer_address.to_string(False) + self.emit( + 'connection', {'peer_name': peer_name, 'peer_address': peer_address} + ) + + def on_bluetooth_disconnection(self, reason): + print(f'Disconnection ({reason})') + self.connection = None + self.emit('disconnection', None) + + def on_avdtp_connection(self, protocol): + print('Audio Stream Open') + + # Add a sink endpoint to the server + sink = protocol.add_sink(self.codec_capabilities()) + sink.on('start', self.on_sink_start) + sink.on('stop', self.on_sink_stop) + sink.on('suspend', self.on_sink_suspend) + sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration)) + sink.on('rtp_packet', self.on_rtp_packet) + sink.on('rtp_channel_open', self.on_rtp_channel_open) + sink.on('rtp_channel_close', self.on_rtp_channel_close) + + # Listen for close events + protocol.on('close', self.on_avdtp_close) + + def on_avdtp_close(self): + print("Audio Stream Closed") + + def on_sink_start(self): + print("Sink Started") + self.stream_state = self.StreamState.STARTED + self.emit('start', None) + + def on_sink_stop(self): + print("Sink Stopped") + self.stream_state = self.StreamState.STOPPED + self.emit('stop', None) + + def on_sink_suspend(self): + print("Sink Suspended") + self.stream_state = self.StreamState.SUSPENDED + self.emit('suspend', None) + + def on_sink_configuration(self, config): + print("Sink Configuration:") + print('\n'.join([" " + str(capability) for capability in config])) + + def on_rtp_channel_open(self): + print("RTP Channel Open") + + def on_rtp_channel_close(self): + print("RTP Channel Closed") + self.stream_state = self.StreamState.IDLE + + def on_rtp_packet(self, packet): + self.packets_received += 1 + self.bytes_received += len(packet.payload) + self.emit("audio", self.audio_extractor.extract_audio(packet)) + + async def connect(self, address): + # Connect to the source + print(f'=== Connecting to {address}...') + connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT) + print(f'=== Connected to {connection.peer_address}') + + # Request authentication + print('*** Authenticating...') + await connection.authenticate() + print('*** Authenticated') + + # Enable encryption + print('*** Enabling encryption...') + await connection.encrypt() + print('*** Encryption on') + + protocol = await Protocol.connect(connection) + self.avdtp_listener.set_server(connection, protocol) + self.on_avdtp_connection(protocol) + + async def discover_remote_endpoints(self, protocol): + endpoints = await protocol.discover_remote_endpoints() + print(f'@@@ Found {len(endpoints)} endpoints') + for endpoint in endpoints: + print('@@@', endpoint) + + def on(self, event_name, listener): + self.js_listeners[event_name] = listener + + def emit(self, event_name, event=None): + if listener := self.js_listeners.get(event_name): + listener(event) + + async def run(self, connect_address): + # Create a device + device_config = DeviceConfiguration() + device_config.name = "Bumble Speaker" + device_config.class_of_device = 0x240414 + device_config.keystore = "JsonKeyStore:/bumble/keystore.json" + device_config.classic_enabled = True + device_config.le_enabled = False + self.device = Device.from_config_with_hci( + device_config, self.hci_source, self.hci_sink + ) + + # Setup the SDP to expose the sink service + self.device.sdp_service_records = self.sdp_records() + + # Don't require MITM when pairing. + self.device.pairing_config_factory = lambda connection: PairingConfig( + mitm=False + ) + + # Start the controller + await self.device.power_on() + + # Listen for Bluetooth connections + self.device.on('connection', self.on_bluetooth_connection) + + # Listen for changes to the key store + self.device.on('key_store_update', self.on_key_store_update) + + # Create a listener to wait for AVDTP connections + self.avdtp_listener = Listener.for_device(self.device) + self.avdtp_listener.on('connection', self.on_avdtp_connection) + + print(f'Speaker ready to play, codec={self.codec}') + + if connect_address: + # Connect to the source + try: + await self.connect(connect_address) + except CommandTimeoutError: + print("Connection timed out") + return + else: + # We'll wait for a connection + print("Waiting for connection...") + + async def start(self): + await self.run(None) + + 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('Speaker stopped') + + +# ----------------------------------------------------------------------------- +def main(hci_source, hci_sink): + return Speaker(hci_source, hci_sink, "aac") diff --git a/hive/web/ui.js b/hive/web/ui.js new file mode 100644 index 00000000..a72ab67c --- /dev/null +++ b/hive/web/ui.js @@ -0,0 +1,102 @@ +import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js'; + +class BumbleControls extends LitElement { + constructor() { + super(); + this.bumbleLoaded = false; + this.connected = false; + } + + render() { + return html` + + +

    WebSocket URL for HCI transport

    +
    + + + +
    +
    + + + + ` + } + + get settingsHciUrlInput() { + return this.renderRoot.querySelector('#settings-hci-url-input'); + } + + get settingsDialog() { + return this.renderRoot.querySelector('#settings-dialog'); + } + + canConnect() { + return this.bumbleLoaded && !this.connected && this.getHciUrl(); + } + + getHciUrl() { + // Look for a URL parameter setting first. + const params = (new URL(document.location)).searchParams; + let hciWsUrl = params.get("hci"); + if (hciWsUrl) { + return hciWsUrl; + } + + // Try to load the setting from storage. + hciWsUrl = localStorage.getItem("hciWsUrl"); + if (hciWsUrl) { + return hciWsUrl; + } + + // Finally, default to nothing. + return null; + } + + openSettingsDialog() { + const hciUrl = this.getHciUrl(); + if (hciUrl) { + this.settingsHciUrlInput.value = hciUrl; + } else { + // Start with a template. + this.settingsHciUrlInput.value = "ws://localhost:XYZW/v1/websocket/bt" + } + this.settingsDialog.showModal(); + } + + onSettingsDialogClose() { + if (this.settingsDialog.returnValue === "cancel") { + return; + } + if (this.settingsHciUrlInput.value) { + localStorage.setItem("hciWsUrl", this.settingsHciUrlInput.value); + } else { + localStorage.removeItem("hciWsUrl"); + } + + this.requestUpdate(); + } + + saveSettings(event) { + event.preventDefault(); + this.settingsDialog.close(this.settingsHciUrlInput.value); + } + + async connectBluetooth() { + this.connected = await this.connector(this.getHciUrl()); + this.requestUpdate(); + } + + async stop() { + await this.stopper(); + this.connected = false; + this.requestUpdate(); + } + + onBumbleLoaded() { + this.bumbleLoaded = true; + this.requestUpdate(); + } +} +customElements.define('bumble-controls', BumbleControls); diff --git a/index.html b/index.html index 75a63691..96a1285b 100644 --- a/index.html +++ b/index.html @@ -1903,6 +1903,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/platforms/android.html b/platforms/android.html index e582e9f4..8d64dfe5 100644 --- a/platforms/android.html +++ b/platforms/android.html @@ -1762,6 +1762,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/platforms/index.html b/platforms/index.html index 68d5bba1..418db3a0 100644 --- a/platforms/index.html +++ b/platforms/index.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/platforms/linux.html b/platforms/linux.html index e7cf98b6..33ed9d94 100644 --- a/platforms/linux.html +++ b/platforms/linux.html @@ -1829,6 +1829,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/platforms/macos.html b/platforms/macos.html index 88e20778..ff543044 100644 --- a/platforms/macos.html +++ b/platforms/macos.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/platforms/windows.html b/platforms/windows.html index 5ec700da..39dd0b0b 100644 --- a/platforms/windows.html +++ b/platforms/windows.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/platforms/zephyr.html b/platforms/zephyr.html index c05668f1..fdfe0830 100644 --- a/platforms/zephyr.html +++ b/platforms/zephyr.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/sitemap.xml.gz b/sitemap.xml.gz index c404fb1c1a6a49739b2ac0f6a16f2d11ed855dee..45848c0f0c14dfae49cd15e7bf9d3370719e33b9 100644 GIT binary patch delta 13 Ucmb=gXP58h;OK6$o5)@P031&Q8vp + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + @@ -1789,9 +1995,9 @@ Use the android-netsim transport name instead.

    connections.

    Moniker

    The moniker syntax for an Android Emulator "netsim" transport is: android-netsim:[<host>:<port>][<options>], -where <options> is a ','-separated list of <name>=<value> pairs. -Themodeparameter name can specify running as a host or a controller, and:can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode). -Both themode=and:parameters are optional (so the monikerandroid-netsimby itself is a valid moniker, which will create a transport inhostmode, connected tolocalhost` on the default gRPC port for the Netsim background process).

    +where <options> is a comma-separated list of <name>=<value> pairs. +The mode parameter name can specify running as a host or a controller, and <hostname>:<port> can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode). +Both the mode=<host|controller> and <hostname>:<port> parameters are optional (so the moniker android-netsim by itself is a valid moniker, which will create a transport in host mode, connected to localhost on the default gRPC port for the Netsim background process).

    Example

    android-netsim diff --git a/transports/file.html b/transports/file.html index 78adbaaf..61a89718 100644 --- a/transports/file.html +++ b/transports/file.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +

  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + +
    diff --git a/transports/hci_socket.html b/transports/hci_socket.html index d9bebabe..97b763a4 100644 --- a/transports/hci_socket.html +++ b/transports/hci_socket.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/index.html b/transports/index.html index bf1fc5b2..f1ce089e 100644 --- a/transports/index.html +++ b/transports/index.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/pty.html b/transports/pty.html index e0f8810d..25e55fbc 100644 --- a/transports/pty.html +++ b/transports/pty.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/serial.html b/transports/serial.html index 375aafa9..baca3afd 100644 --- a/transports/serial.html +++ b/transports/serial.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/tcp_client.html b/transports/tcp_client.html index dda5c2f0..51a1770a 100644 --- a/transports/tcp_client.html +++ b/transports/tcp_client.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/tcp_server.html b/transports/tcp_server.html index 6b040025..7abea239 100644 --- a/transports/tcp_server.html +++ b/transports/tcp_server.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/udp.html b/transports/udp.html index a310d182..6e4e8440 100644 --- a/transports/udp.html +++ b/transports/udp.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/usb.html b/transports/usb.html index e85fec4b..e362194a 100644 --- a/transports/usb.html +++ b/transports/usb.html @@ -1789,6 +1789,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/vhci.html b/transports/vhci.html index 2f1bea5c..e5bbd355 100644 --- a/transports/vhci.html +++ b/transports/vhci.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/ws_client.html b/transports/ws_client.html index 3a693e4c..a644af1c 100644 --- a/transports/ws_client.html +++ b/transports/ws_client.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/transports/ws_server.html b/transports/ws_server.html index 6ab1a45c..13c7193b 100644 --- a/transports/ws_server.html +++ b/transports/ws_server.html @@ -1728,6 +1728,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/use_cases/index.html b/use_cases/index.html index a3dfbd97..3313aca1 100644 --- a/use_cases/index.html +++ b/use_cases/index.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/use_cases/use_case_1.html b/use_cases/use_case_1.html index 86629ec6..fc7defb1 100644 --- a/use_cases/use_case_1.html +++ b/use_cases/use_case_1.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/use_cases/use_case_2.html b/use_cases/use_case_2.html index af804af8..770dd850 100644 --- a/use_cases/use_case_2.html +++ b/use_cases/use_case_2.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/use_cases/use_case_3.html b/use_cases/use_case_3.html index 7192ef47..e23f0174 100644 --- a/use_cases/use_case_3.html +++ b/use_cases/use_case_3.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/use_cases/use_case_4.html b/use_cases/use_case_4.html index bb2cea75..1e86be95 100644 --- a/use_cases/use_case_4.html +++ b/use_cases/use_case_4.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/use_cases/use_case_5.html b/use_cases/use_case_5.html index 2ed5ab4a..6e795b72 100644 --- a/use_cases/use_case_5.html +++ b/use_cases/use_case_5.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + diff --git a/use_cases/use_case_6.html b/use_cases/use_case_6.html index 6de595e1..b182be3b 100644 --- a/use_cases/use_case_6.html +++ b/use_cases/use_case_6.html @@ -1691,6 +1691,212 @@ + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + + + + + + + + + + + + +
  • + + + + + + + + + + + +
  • + + +