brilliant-ble 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brilliant_ble-3.0.0/.gitignore +21 -0
- brilliant_ble-3.0.0/LICENSE +28 -0
- brilliant_ble-3.0.0/PKG-INFO +62 -0
- brilliant_ble-3.0.0/README.md +37 -0
- brilliant_ble-3.0.0/pyproject.toml +53 -0
- brilliant_ble-3.0.0/src/brilliant_ble/__init__.py +9 -0
- brilliant_ble-3.0.0/src/brilliant_ble/brilliant_ble.py +428 -0
- brilliant_ble-3.0.0/tests/audio/female_w1_8k_s16.lc3 +0 -0
- brilliant_ble-3.0.0/tests/audio/female_w1_8k_s8.pcm +0 -0
- brilliant_ble-3.0.0/tests/lua/test.lua +15 -0
- brilliant_ble-3.0.0/tests/test_aad.py +53 -0
- brilliant_ble-3.0.0/tests/test_battery.py +27 -0
- brilliant_ble-3.0.0/tests/test_ble.py +127 -0
- brilliant_ble-3.0.0/tests/test_bluetooth_callback_api.py +27 -0
- brilliant_ble-3.0.0/tests/test_bluetooth_throughput.py +55 -0
- brilliant_ble-3.0.0/tests/test_button.py +33 -0
- brilliant_ble-3.0.0/tests/test_camera.py +85 -0
- brilliant_ble-3.0.0/tests/test_compression.py +52 -0
- brilliant_ble-3.0.0/tests/test_display.py +72 -0
- brilliant_ble-3.0.0/tests/test_display_bitmap.py +182 -0
- brilliant_ble-3.0.0/tests/test_file_api.py +147 -0
- brilliant_ble-3.0.0/tests/test_file_execution.py +113 -0
- brilliant_ble-3.0.0/tests/test_imu_direction.py +31 -0
- brilliant_ble-3.0.0/tests/test_imu_raw.py +32 -0
- brilliant_ble-3.0.0/tests/test_microphone.py +110 -0
- brilliant_ble-3.0.0/tests/test_microphone_lc3.py +137 -0
- brilliant_ble-3.0.0/tests/test_speaker_lc3.py +47 -0
- brilliant_ble-3.0.0/tests/test_speaker_lc3_2.py +37 -0
- brilliant_ble-3.0.0/tests/test_speaker_pcm.py +51 -0
- brilliant_ble-3.0.0/tests/test_taps.py +24 -0
- brilliant_ble-3.0.0/tests/test_text_api.py +106 -0
- brilliant_ble-3.0.0/tests/test_time.py +94 -0
- brilliant_ble-3.0.0/tests/test_version.py +23 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, CitizenOneX
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brilliant-ble
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Low-level library for Bluetooth LE connection to Brilliant Labs Frame and Halo devices
|
|
5
|
+
Project-URL: Homepage, https://github.com/brilliantlabsAR/brilliant_sdk/tree/main/python/packages/brilliant_ble
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/brilliantlabsAR/brilliant_sdk/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/brilliantlabsAR/brilliant_sdk/blob/main/python/packages/brilliant_ble/CHANGELOG.md
|
|
8
|
+
Author: luke-brilliant
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Requires-Dist: bleak<1.0.0,>=0.22.3
|
|
15
|
+
Provides-Extra: docs
|
|
16
|
+
Requires-Dist: sphinx-rtd-theme>=2.0.0; extra == 'docs'
|
|
17
|
+
Requires-Dist: sphinx>=7.0.0; extra == 'docs'
|
|
18
|
+
Provides-Extra: examples
|
|
19
|
+
Provides-Extra: tests
|
|
20
|
+
Requires-Dist: aioconsole; extra == 'tests'
|
|
21
|
+
Requires-Dist: lc3py; extra == 'tests'
|
|
22
|
+
Requires-Dist: numpy; extra == 'tests'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'tests'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# brilliant-ble
|
|
27
|
+
|
|
28
|
+
Low-level library for Bluetooth LE connection to [Brilliant Labs](https://brilliant.xyz/) Frame and Halo devices.
|
|
29
|
+
|
|
30
|
+
[Frame SDK documentation](https://docs.brilliant.xyz/frame/frame-sdk/).
|
|
31
|
+
|
|
32
|
+
[Examples on GitHub](https://github.com/brilliantlabsAR/brilliant_sdk/tree/main/python/packages/brilliant_ble/examples).
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv add brilliant-ble
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from brilliant_ble import BrilliantBle
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
frame = BrilliantBle()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
await frame.connect()
|
|
51
|
+
|
|
52
|
+
await frame.send_lua("frame.display.text('Hello, World!', 1, 1);frame.display.show();print(nil)", await_print=True)
|
|
53
|
+
|
|
54
|
+
await frame.disconnect()
|
|
55
|
+
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"Not connected to Device: {e}")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# brilliant-ble
|
|
2
|
+
|
|
3
|
+
Low-level library for Bluetooth LE connection to [Brilliant Labs](https://brilliant.xyz/) Frame and Halo devices.
|
|
4
|
+
|
|
5
|
+
[Frame SDK documentation](https://docs.brilliant.xyz/frame/frame-sdk/).
|
|
6
|
+
|
|
7
|
+
[Examples on GitHub](https://github.com/brilliantlabsAR/brilliant_sdk/tree/main/python/packages/brilliant_ble/examples).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add brilliant-ble
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
from brilliant_ble import BrilliantBle
|
|
20
|
+
|
|
21
|
+
async def main():
|
|
22
|
+
frame = BrilliantBle()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
await frame.connect()
|
|
26
|
+
|
|
27
|
+
await frame.send_lua("frame.display.text('Hello, World!', 1, 1);frame.display.show();print(nil)", await_print=True)
|
|
28
|
+
|
|
29
|
+
await frame.disconnect()
|
|
30
|
+
|
|
31
|
+
except Exception as e:
|
|
32
|
+
print(f"Not connected to Device: {e}")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "brilliant-ble"
|
|
7
|
+
version = "3.0.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"bleak >= 0.22.3, < 1.0.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "luke-brilliant" },
|
|
14
|
+
]
|
|
15
|
+
description = "Low-level library for Bluetooth LE connection to Brilliant Labs Frame and Halo devices"
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
requires-python = ">=3.7"
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"License :: OSI Approved :: BSD License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
examples = [
|
|
26
|
+
]
|
|
27
|
+
tests = [
|
|
28
|
+
"aioconsole",
|
|
29
|
+
"numpy",
|
|
30
|
+
"lc3py",
|
|
31
|
+
"pytest>=8.0",
|
|
32
|
+
]
|
|
33
|
+
docs = [
|
|
34
|
+
"sphinx>=7.0.0",
|
|
35
|
+
"sphinx-rtd-theme>=2.0.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/brilliant_ble"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = [
|
|
46
|
+
"/src",
|
|
47
|
+
"/tests",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.urls]
|
|
51
|
+
"Homepage" = "https://github.com/brilliantlabsAR/brilliant_sdk/tree/main/python/packages/brilliant_ble"
|
|
52
|
+
"Bug Tracker" = "https://github.com/brilliantlabsAR/brilliant_sdk/issues"
|
|
53
|
+
"Changelog" = "https://github.com/brilliantlabsAR/brilliant_sdk/blob/main/python/packages/brilliant_ble/CHANGELOG.md"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-level library for Bluetooth LE connection to Brilliant Labs Frame and Halo devices (https://brilliant.xyz/)
|
|
3
|
+
"""
|
|
4
|
+
__all__ = ["brilliant_ble"]
|
|
5
|
+
|
|
6
|
+
from .brilliant_ble import BrilliantBle
|
|
7
|
+
from .brilliant_ble import BrilliantDeviceType
|
|
8
|
+
|
|
9
|
+
__version__ = "3.0.0"
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from bleak import BleakClient, BleakScanner, BleakError
|
|
5
|
+
from typing import Final
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
class BrilliantDeviceType(Enum):
|
|
9
|
+
FRAME = "Frame"
|
|
10
|
+
HALO = "Halo"
|
|
11
|
+
UNKNOWN = "Unknown"
|
|
12
|
+
|
|
13
|
+
class BrilliantBle:
|
|
14
|
+
"""
|
|
15
|
+
Class for managing a connection and transferring data to and
|
|
16
|
+
from the Brilliant Labs Halo/Frame device over Bluetooth LE using the Bleak library.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_SERVICE_UUID = "7a230001-5475-a6a4-654c-8431f6ad49c4"
|
|
20
|
+
_TX_CHARACTERISTIC_UUID = "7a230002-5475-a6a4-654c-8431f6ad49c4"
|
|
21
|
+
_RX_CHARACTERISTIC_UUID = "7a230003-5475-a6a4-654c-8431f6ad49c4"
|
|
22
|
+
_AUDIO_TX_CHARACTERISTIC_UUID = "7a230005-5475-a6a4-654c-8431f6ad49c4"
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self._awaiting_print_response = False
|
|
26
|
+
self._awaiting_data_response = False
|
|
27
|
+
self._client = None
|
|
28
|
+
self._print_response = asyncio.Queue()
|
|
29
|
+
self._data_response = asyncio.Queue()
|
|
30
|
+
self._tx_characteristic = None
|
|
31
|
+
self._rx_characteristic = None
|
|
32
|
+
self._audio_tx_characteristic = None
|
|
33
|
+
self._user_data_response_handler = lambda: None
|
|
34
|
+
self._user_disconnect_handler = lambda: None
|
|
35
|
+
self._user_print_response_handler = lambda: None
|
|
36
|
+
self._type = BrilliantDeviceType.UNKNOWN
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def type(self):
|
|
40
|
+
"""
|
|
41
|
+
Returns the type of the Brilliant device.
|
|
42
|
+
"""
|
|
43
|
+
return self._type
|
|
44
|
+
|
|
45
|
+
def _disconnect_handler(self, _):
|
|
46
|
+
self._user_disconnect_handler()
|
|
47
|
+
self.__init__()
|
|
48
|
+
|
|
49
|
+
async def _notification_handler(self, _, data):
|
|
50
|
+
if data[0] == 1:
|
|
51
|
+
# Use memoryview to avoid copying
|
|
52
|
+
data_view = memoryview(data)[1:]
|
|
53
|
+
|
|
54
|
+
if self._awaiting_data_response:
|
|
55
|
+
self._awaiting_data_response = False
|
|
56
|
+
await self._data_response.put(data_view)
|
|
57
|
+
|
|
58
|
+
# call the user response handler on all incoming notifications
|
|
59
|
+
if self._user_data_response_handler is not None:
|
|
60
|
+
if asyncio.iscoroutinefunction(self._user_data_response_handler):
|
|
61
|
+
await self._user_data_response_handler(data_view)
|
|
62
|
+
else:
|
|
63
|
+
self._user_data_response_handler(data_view)
|
|
64
|
+
else:
|
|
65
|
+
# decode the string data only once
|
|
66
|
+
decoded = data.decode()
|
|
67
|
+
|
|
68
|
+
if self._awaiting_print_response:
|
|
69
|
+
self._awaiting_print_response = False
|
|
70
|
+
await self._print_response.put(decoded)
|
|
71
|
+
|
|
72
|
+
# call the user response handler on all incoming notifications
|
|
73
|
+
if self._user_print_response_handler is not None:
|
|
74
|
+
if asyncio.iscoroutinefunction(self._user_print_response_handler):
|
|
75
|
+
await self._user_print_response_handler(decoded)
|
|
76
|
+
else:
|
|
77
|
+
self._user_print_response_handler(decoded)
|
|
78
|
+
|
|
79
|
+
async def connect(
|
|
80
|
+
self,
|
|
81
|
+
name=None,
|
|
82
|
+
timeout=10,
|
|
83
|
+
print_response_handler=lambda _: None,
|
|
84
|
+
data_response_handler=lambda _: None,
|
|
85
|
+
disconnect_handler=lambda: None,
|
|
86
|
+
):
|
|
87
|
+
"""
|
|
88
|
+
Connects to the first Halo/Frame device discovered,
|
|
89
|
+
optionally matching a specified name e.g. "Halo AB",
|
|
90
|
+
or throws an Exception if a matching Frame is not found within timeout seconds.
|
|
91
|
+
|
|
92
|
+
`name` can optionally be provided as the local name containing the
|
|
93
|
+
2 digit ID, in order to only connect to that specific device.
|
|
94
|
+
The value should be a string, for example `"Frame 4F"`
|
|
95
|
+
|
|
96
|
+
`print_response_handler` and `data_response_handler` can be provided and
|
|
97
|
+
will be called whenever data arrives from the device asynchronously.
|
|
98
|
+
|
|
99
|
+
`disconnect_handler` can be provided to be called to run
|
|
100
|
+
upon a disconnect.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
self._user_disconnect_handler = disconnect_handler
|
|
104
|
+
self._user_print_response_handler = print_response_handler
|
|
105
|
+
self._user_data_response_handler = data_response_handler
|
|
106
|
+
|
|
107
|
+
# Create a scanner with a filter for our service UUID and optional name
|
|
108
|
+
device = await BleakScanner.find_device_by_filter(
|
|
109
|
+
lambda d, _: d.name is not None and (name is None or d.name == name),
|
|
110
|
+
timeout=timeout,
|
|
111
|
+
service_uuids=[self._SERVICE_UUID]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not device:
|
|
115
|
+
raise Exception("No matching device found")
|
|
116
|
+
|
|
117
|
+
self._client = BleakClient(
|
|
118
|
+
device,
|
|
119
|
+
disconnected_callback=self._disconnect_handler,
|
|
120
|
+
winrt=dict(use_cached_services=False)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
await self._client.connect()
|
|
125
|
+
# Workaround to acquire MTU size because Bleak doesn't do it automatically when using BlueZ backend
|
|
126
|
+
if self._client._backend.__class__.__name__ == "BleakClientBlueZDBus":
|
|
127
|
+
await self._client._backend._acquire_mtu()
|
|
128
|
+
except BleakError as ble_error:
|
|
129
|
+
raise Exception(f"Error connecting: {ble_error}")
|
|
130
|
+
|
|
131
|
+
service = self._client.services.get_service(
|
|
132
|
+
self._SERVICE_UUID,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self._tx_characteristic = service.get_characteristic(
|
|
136
|
+
self._TX_CHARACTERISTIC_UUID,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
self._rx_characteristic = service.get_characteristic(
|
|
140
|
+
self._RX_CHARACTERISTIC_UUID,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._audio_tx_characteristic = service.get_characteristic(
|
|
144
|
+
self._AUDIO_TX_CHARACTERISTIC_UUID,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if self._audio_tx_characteristic is not None:
|
|
148
|
+
self._type = BrilliantDeviceType.HALO
|
|
149
|
+
else:
|
|
150
|
+
self._type = BrilliantDeviceType.FRAME
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
await self._client.start_notify(
|
|
154
|
+
self._RX_CHARACTERISTIC_UUID,
|
|
155
|
+
self._notification_handler,
|
|
156
|
+
)
|
|
157
|
+
except Exception as ble_error:
|
|
158
|
+
raise Exception(f"Error subscribing for notifications: {ble_error}")
|
|
159
|
+
|
|
160
|
+
return device.name
|
|
161
|
+
|
|
162
|
+
async def disconnect(self):
|
|
163
|
+
"""
|
|
164
|
+
Disconnects from the device.
|
|
165
|
+
"""
|
|
166
|
+
if (self._client is not None):
|
|
167
|
+
await self._client.disconnect()
|
|
168
|
+
self._disconnect_handler(None)
|
|
169
|
+
|
|
170
|
+
def is_connected(self):
|
|
171
|
+
"""
|
|
172
|
+
Returns `True` if the device is connected. `False` otherwise.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
return (self._client is not None) and self._client.is_connected
|
|
176
|
+
except AttributeError:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def max_lua_payload(self):
|
|
180
|
+
"""
|
|
181
|
+
Returns the maximum length of a Lua string which may be transmitted.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
return min(self._client.mtu_size, 512) - 3
|
|
185
|
+
except AttributeError:
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
def max_data_payload(self):
|
|
189
|
+
"""
|
|
190
|
+
Returns the maximum length of a raw bytearray which may be transmitted.
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
return min(self._client.mtu_size, 512) - 4
|
|
194
|
+
except AttributeError:
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
async def _transmit(self, data, show_me=False, await_bt_response=True):
|
|
198
|
+
if show_me:
|
|
199
|
+
print(data) # TODO make this print nicer
|
|
200
|
+
|
|
201
|
+
if len(data) > self._client.mtu_size - 3:
|
|
202
|
+
raise Exception("payload length is too large")
|
|
203
|
+
|
|
204
|
+
await self._client.write_gatt_char(self._tx_characteristic, data, response=await_bt_response)
|
|
205
|
+
|
|
206
|
+
async def send_lua(self, string: str, show_me=False, await_print=False, timeout=5):
|
|
207
|
+
"""
|
|
208
|
+
Sends a Lua string to the device. The string length must be less than or
|
|
209
|
+
equal to `max_lua_payload()`.
|
|
210
|
+
|
|
211
|
+
If `await_print=True`, the function will block until a Lua print()
|
|
212
|
+
occurs, or a timeout (in seconds).
|
|
213
|
+
|
|
214
|
+
If `show_me=True`, the exact bytes send to the device will be printed.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
# set the awaiting status before we transmit
|
|
218
|
+
self._awaiting_print_response = await_print
|
|
219
|
+
|
|
220
|
+
await self._transmit(string.encode(), show_me=show_me)
|
|
221
|
+
|
|
222
|
+
if await_print:
|
|
223
|
+
try:
|
|
224
|
+
return await asyncio.wait_for(self._print_response.get(), timeout=timeout)
|
|
225
|
+
except asyncio.TimeoutError:
|
|
226
|
+
raise Exception("device didn't respond")
|
|
227
|
+
|
|
228
|
+
async def send_data(self, data: bytearray, show_me=False, await_data=False, timeout=5, await_bt_response=True):
|
|
229
|
+
"""
|
|
230
|
+
Sends raw data to the device. The payload length must be less than or
|
|
231
|
+
equal to `max_data_payload()`.
|
|
232
|
+
|
|
233
|
+
If `await_data=True`, the function will block until a data response
|
|
234
|
+
occurs, or a timeout (in seconds).
|
|
235
|
+
|
|
236
|
+
If `show_me=True`, the exact bytes send to the device will be printed.
|
|
237
|
+
"""
|
|
238
|
+
# set the awaiting status before we transmit
|
|
239
|
+
self._awaiting_data_response = await_data
|
|
240
|
+
|
|
241
|
+
await self._transmit(bytearray(b"\x01") + data, show_me=show_me, await_bt_response=await_bt_response)
|
|
242
|
+
|
|
243
|
+
if await_data:
|
|
244
|
+
try:
|
|
245
|
+
return await asyncio.wait_for(self._data_response.get(), timeout=timeout)
|
|
246
|
+
except asyncio.TimeoutError:
|
|
247
|
+
raise Exception("device didn't respond")
|
|
248
|
+
|
|
249
|
+
async def send_audio(self, data: bytearray, await_bt_response=False):
|
|
250
|
+
"""
|
|
251
|
+
Sends audio data to the custom audio characteristic of the device.
|
|
252
|
+
await_bt_response corresponds to bluetooth "writeWithResponse", so
|
|
253
|
+
a bluetooth-level ACK will be required for each write (defaults to False)
|
|
254
|
+
|
|
255
|
+
Note:
|
|
256
|
+
Ensure that whole LC3 frames fit within MTU if sending LC3,
|
|
257
|
+
or even numbers of samples are provided in the case of PCM
|
|
258
|
+
If data exceeds a single packet payload (mtu-3) the audio packet is silently dropped
|
|
259
|
+
"""
|
|
260
|
+
mtu = self._client.mtu_size - 3
|
|
261
|
+
if len(data) > mtu:
|
|
262
|
+
return
|
|
263
|
+
# stream audio as write-without-response (response=False)
|
|
264
|
+
await self._client.write_gatt_char(self._audio_tx_characteristic, data, response=await_bt_response)
|
|
265
|
+
|
|
266
|
+
async def send_reset_signal(self, show_me=False):
|
|
267
|
+
"""
|
|
268
|
+
Sends a reset signal to the device which will reset the Lua virtual
|
|
269
|
+
machine.
|
|
270
|
+
|
|
271
|
+
If `show_me=True`, the exact bytes send to the device will be printed.
|
|
272
|
+
"""
|
|
273
|
+
await self._transmit(bytearray(b"\x04"), show_me=show_me)
|
|
274
|
+
# need to give it a moment after the Lua VM reset before it can handle any requests
|
|
275
|
+
await asyncio.sleep(0.2)
|
|
276
|
+
|
|
277
|
+
async def send_remove_signal(self, show_me=False):
|
|
278
|
+
"""
|
|
279
|
+
Sends a remove signal to the device which will remove the main.lua file from Halo.
|
|
280
|
+
This is only applicable to Halo devices.
|
|
281
|
+
|
|
282
|
+
If `show_me=True`, the exact bytes send to the device will be printed.
|
|
283
|
+
"""
|
|
284
|
+
await self._transmit(bytearray(b"\x05"), show_me=show_me)
|
|
285
|
+
# need to give it a moment after the Lua VM reset before it can handle any requests
|
|
286
|
+
await asyncio.sleep(0.2)
|
|
287
|
+
|
|
288
|
+
async def send_break_signal(self, show_me=False):
|
|
289
|
+
"""
|
|
290
|
+
Sends a break signal to the device which will break any currently
|
|
291
|
+
executing Lua script.
|
|
292
|
+
|
|
293
|
+
If `show_me=True`, the exact bytes send to the device will be printed.
|
|
294
|
+
"""
|
|
295
|
+
await self._transmit(bytearray(b"\x03"), show_me=show_me)
|
|
296
|
+
# need to give it a moment after the break before it can handle any requests
|
|
297
|
+
await asyncio.sleep(0.2)
|
|
298
|
+
|
|
299
|
+
async def upload_file_from_string(self, content: str, frame_file_path="main.lua"):
|
|
300
|
+
"""
|
|
301
|
+
Uploads a string as frame_file_path. If the file exists, it will be overwritten.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
content (str): The string content to upload
|
|
305
|
+
frame_file_path (str): Target file path on the frame
|
|
306
|
+
"""
|
|
307
|
+
# Escape special characters
|
|
308
|
+
content = (content.replace("\r", "")
|
|
309
|
+
.replace("\\", "\\\\")
|
|
310
|
+
.replace("\n", "\\n")
|
|
311
|
+
.replace("\t", "\\t")
|
|
312
|
+
.replace("'", "\\'")
|
|
313
|
+
.replace('"', '\\"'))
|
|
314
|
+
|
|
315
|
+
# Open the file on the frame
|
|
316
|
+
await self.send_lua(
|
|
317
|
+
f"f=frame.file.open('{frame_file_path}','w');print(1)",
|
|
318
|
+
await_print=True
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Calculate chunk size accounting for the Lua command overhead
|
|
322
|
+
chunk_size: int = self.max_lua_payload() - 22
|
|
323
|
+
|
|
324
|
+
# Upload in chunks
|
|
325
|
+
i = 0
|
|
326
|
+
while i < len(content):
|
|
327
|
+
# Calculate initial chunk size
|
|
328
|
+
current_chunk_size = min(chunk_size, len(content) - i)
|
|
329
|
+
|
|
330
|
+
# Check for escape sequences at the chunk boundary
|
|
331
|
+
if current_chunk_size < len(content) - i:
|
|
332
|
+
# Look for the last non-escaped backslash in the chunk
|
|
333
|
+
pos = i + current_chunk_size - 1
|
|
334
|
+
while pos > i:
|
|
335
|
+
# Check if we're in the middle of an escape sequence
|
|
336
|
+
if content[pos] == '\\' and (pos == i or content[pos-1] != '\\'):
|
|
337
|
+
# If we find an unescaped backslash, adjust the chunk size
|
|
338
|
+
current_chunk_size = pos - i
|
|
339
|
+
break
|
|
340
|
+
pos -= 1
|
|
341
|
+
|
|
342
|
+
chunk: str = content[i:i + current_chunk_size]
|
|
343
|
+
await self.send_lua(f'f:write("{chunk}");print(1)', await_print=True)
|
|
344
|
+
i += current_chunk_size
|
|
345
|
+
|
|
346
|
+
# Close the file
|
|
347
|
+
await self.send_lua("f:close();print(nil)", await_print=True)
|
|
348
|
+
|
|
349
|
+
async def upload_file(self, local_file_path: str, frame_file_path="main.lua"):
|
|
350
|
+
"""
|
|
351
|
+
Uploads a local file to the frame. If the target file exists, it will be overwritten.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
local_file_path (str): Path to the local file to upload. Must exist.
|
|
355
|
+
frame_file_path (str): Target file path on the frame
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
FileNotFoundError: If local_file_path doesn't exist
|
|
359
|
+
"""
|
|
360
|
+
if not os.path.exists(local_file_path):
|
|
361
|
+
raise FileNotFoundError(f"Local file not found: {local_file_path}")
|
|
362
|
+
|
|
363
|
+
with open(local_file_path, "r") as f:
|
|
364
|
+
content = f.read()
|
|
365
|
+
|
|
366
|
+
await self.upload_file_from_string(content, frame_file_path)
|
|
367
|
+
|
|
368
|
+
async def send_message(self, msg_code: int, payload: bytes, show_me: bool=False) -> None:
|
|
369
|
+
"""
|
|
370
|
+
Send a large payload in chunks determined by BLE MTU size.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
msg_code (int): Message type identifier (0-255)
|
|
374
|
+
payload (bytes): Data to be sent
|
|
375
|
+
show_me (bool): If True, the exact bytes send to the device will be printed
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
ValueError: If msg_code is not in range 0-255 or payload size exceeds 65535
|
|
379
|
+
|
|
380
|
+
Note:
|
|
381
|
+
First packet format: [msg_code(1), size_high(1), size_low(1), data(...)]
|
|
382
|
+
Other packets format: [msg_code(1), data(...)]
|
|
383
|
+
"""
|
|
384
|
+
# Constants
|
|
385
|
+
HEADER_SIZE: Final = 3 # msg_code + 2 bytes size
|
|
386
|
+
SUBSEQUENT_HEADER_SIZE: Final = 1 # just msg_code
|
|
387
|
+
MAX_TOTAL_SIZE: Final = 65535 # 2^16 - 1, maximum size that fits in 2 bytes
|
|
388
|
+
|
|
389
|
+
# Validation
|
|
390
|
+
if not 0 <= msg_code <= 255:
|
|
391
|
+
raise ValueError(f"Message code must be 0-255, got {msg_code}")
|
|
392
|
+
|
|
393
|
+
total_size = len(payload)
|
|
394
|
+
if total_size > MAX_TOTAL_SIZE:
|
|
395
|
+
raise ValueError(f"Payload size {total_size} exceeds maximum {MAX_TOTAL_SIZE} bytes")
|
|
396
|
+
|
|
397
|
+
# Calculate maximum chunk sizes
|
|
398
|
+
max_first_chunk = self.max_data_payload() - HEADER_SIZE
|
|
399
|
+
max_chunk_size = self.max_data_payload() - SUBSEQUENT_HEADER_SIZE
|
|
400
|
+
|
|
401
|
+
# Pre-allocate buffer for maximum sized packets
|
|
402
|
+
buffer = bytearray(self.max_data_payload())
|
|
403
|
+
|
|
404
|
+
# Send first chunk
|
|
405
|
+
first_chunk_size = min(max_first_chunk, total_size)
|
|
406
|
+
buffer[0] = msg_code
|
|
407
|
+
buffer[1] = total_size >> 8
|
|
408
|
+
buffer[2] = total_size & 0xFF
|
|
409
|
+
buffer[HEADER_SIZE:HEADER_SIZE + first_chunk_size] = payload[:first_chunk_size]
|
|
410
|
+
await self.send_data(memoryview(buffer)[:HEADER_SIZE + first_chunk_size], show_me=show_me, await_data=True)
|
|
411
|
+
sent_bytes = first_chunk_size
|
|
412
|
+
|
|
413
|
+
# Send remaining chunks
|
|
414
|
+
if sent_bytes < total_size:
|
|
415
|
+
# Set message code in the reusable buffer
|
|
416
|
+
buffer[0] = msg_code
|
|
417
|
+
|
|
418
|
+
while sent_bytes < total_size:
|
|
419
|
+
remaining = total_size - sent_bytes
|
|
420
|
+
chunk_size = min(max_chunk_size, remaining)
|
|
421
|
+
|
|
422
|
+
# Copy next chunk into the pre-allocated buffer
|
|
423
|
+
buffer[SUBSEQUENT_HEADER_SIZE:SUBSEQUENT_HEADER_SIZE + chunk_size] = \
|
|
424
|
+
payload[sent_bytes:sent_bytes + chunk_size]
|
|
425
|
+
|
|
426
|
+
# Send only the used portion of the buffer
|
|
427
|
+
await self.send_data(memoryview(buffer)[:SUBSEQUENT_HEADER_SIZE + chunk_size], show_me=show_me, await_data=True)
|
|
428
|
+
sent_bytes += chunk_size
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
for i = 1, 3 do
|
|
2
|
+
frame.display.text('Hello world!', 10, 10)
|
|
3
|
+
frame.display.show()
|
|
4
|
+
frame.sleep(1)
|
|
5
|
+
|
|
6
|
+
if frame.HARDWARE_VERSION == 'Frame' then
|
|
7
|
+
frame.display.text('Test was run from file', 10, 10, { color = 'GREEN' })
|
|
8
|
+
frame.display.show()
|
|
9
|
+
else
|
|
10
|
+
frame.display.clear()
|
|
11
|
+
frame.display.text('Test was run from file', 10, 10, 0x00FF00)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
frame.sleep(1)
|
|
15
|
+
end
|