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.
Files changed (33) hide show
  1. brilliant_ble-3.0.0/.gitignore +21 -0
  2. brilliant_ble-3.0.0/LICENSE +28 -0
  3. brilliant_ble-3.0.0/PKG-INFO +62 -0
  4. brilliant_ble-3.0.0/README.md +37 -0
  5. brilliant_ble-3.0.0/pyproject.toml +53 -0
  6. brilliant_ble-3.0.0/src/brilliant_ble/__init__.py +9 -0
  7. brilliant_ble-3.0.0/src/brilliant_ble/brilliant_ble.py +428 -0
  8. brilliant_ble-3.0.0/tests/audio/female_w1_8k_s16.lc3 +0 -0
  9. brilliant_ble-3.0.0/tests/audio/female_w1_8k_s8.pcm +0 -0
  10. brilliant_ble-3.0.0/tests/lua/test.lua +15 -0
  11. brilliant_ble-3.0.0/tests/test_aad.py +53 -0
  12. brilliant_ble-3.0.0/tests/test_battery.py +27 -0
  13. brilliant_ble-3.0.0/tests/test_ble.py +127 -0
  14. brilliant_ble-3.0.0/tests/test_bluetooth_callback_api.py +27 -0
  15. brilliant_ble-3.0.0/tests/test_bluetooth_throughput.py +55 -0
  16. brilliant_ble-3.0.0/tests/test_button.py +33 -0
  17. brilliant_ble-3.0.0/tests/test_camera.py +85 -0
  18. brilliant_ble-3.0.0/tests/test_compression.py +52 -0
  19. brilliant_ble-3.0.0/tests/test_display.py +72 -0
  20. brilliant_ble-3.0.0/tests/test_display_bitmap.py +182 -0
  21. brilliant_ble-3.0.0/tests/test_file_api.py +147 -0
  22. brilliant_ble-3.0.0/tests/test_file_execution.py +113 -0
  23. brilliant_ble-3.0.0/tests/test_imu_direction.py +31 -0
  24. brilliant_ble-3.0.0/tests/test_imu_raw.py +32 -0
  25. brilliant_ble-3.0.0/tests/test_microphone.py +110 -0
  26. brilliant_ble-3.0.0/tests/test_microphone_lc3.py +137 -0
  27. brilliant_ble-3.0.0/tests/test_speaker_lc3.py +47 -0
  28. brilliant_ble-3.0.0/tests/test_speaker_lc3_2.py +37 -0
  29. brilliant_ble-3.0.0/tests/test_speaker_pcm.py +51 -0
  30. brilliant_ble-3.0.0/tests/test_taps.py +24 -0
  31. brilliant_ble-3.0.0/tests/test_text_api.py +106 -0
  32. brilliant_ble-3.0.0/tests/test_time.py +94 -0
  33. brilliant_ble-3.0.0/tests/test_version.py +23 -0
@@ -0,0 +1,21 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ develop-eggs/
8
+ dist/
9
+ downloads/
10
+ eggs/
11
+ .eggs/
12
+ lib/
13
+ lib64/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ wheels/
18
+ *.egg-info/
19
+ .installed.cfg
20
+ *.egg
21
+ .venv/
@@ -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
@@ -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