Skip to content

Commit 4f3ef2a

Browse files
committed
improve enum typing and tests
1 parent 138a9b8 commit 4f3ef2a

File tree

14 files changed

+300
-223
lines changed

14 files changed

+300
-223
lines changed

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ Here's a simple example of how to use the Frame SDK to display text, take a phot
2424
```python
2525
import asyncio
2626
from frame_sdk import Frame
27-
from frame_sdk.display import Alignment
27+
from frame_sdk.display import Alignment, PaletteColors
28+
from frame_sdk.camera import Quality, AutofocusType
2829
import datetime
2930

3031
async def main():
@@ -59,9 +60,9 @@ async def main():
5960
# take a photo and save to disk
6061
await f.display.show_text("Taking photo...", align=Alignment.MIDDLE_CENTER)
6162
await f.camera.save_photo("frame-test-photo.jpg")
62-
await f.display.show_text("Photo saved!", align=Alignment.MIDDLE_CENTER)
63+
await f.display.show_text("Photo saved!", align=Alignment.MIDDLE_CENTER, color=PaletteColors.GREEN)
6364
# or with more control
64-
await f.camera.save_photo("frame-test-photo-2.jpg", autofocus_seconds=3, quality=f.camera.HIGH_QUALITY, autofocus_type=f.camera.AUTOFOCUS_TYPE_CENTER_WEIGHTED)
65+
await f.camera.save_photo("frame-test-photo-2.jpg", autofocus_seconds=3, quality=Quality.HIGH, autofocus_type=AutofocusType.CENTER_WEIGHTED)
6566
# or get the raw bytes
6667
photo_bytes = await f.camera.take_photo(autofocus_seconds=1)
6768

@@ -78,7 +79,7 @@ async def main():
7879
audio_data = await f.microphone.record_audio(max_length_in_seconds=10)
7980
await f.display.show_text(f"Playing back {len(audio_data) / f.microphone.sample_rate:01.1f} seconds of audio", align=Alignment.MIDDLE_CENTER)
8081
# you can play back the audio on your computer
81-
f.microphone.play_audio(audio_data)
82+
f.microphone.play_audio_background(audio_data)
8283
# or process it using other audio handling libraries, upload to a speech-to-text service, etc.
8384

8485
print("Move around to track intensity of your motion")
@@ -101,7 +102,7 @@ async def main():
101102
for color in range(0, 16):
102103
tile_x = (color % 4)
103104
tile_y = (color // 4)
104-
await f.display.draw_rect(tile_x*width+1, tile_y*height+1, width, height, color)
105+
await f.display.draw_rect(tile_x*width+1, tile_y*height+1, width, height, PaletteColors(color))
105106
await f.display.write_text(f"{color}", tile_x*width+width//2+1, tile_y*height+height//2+1)
106107
await f.display.show()
107108

@@ -114,14 +115,14 @@ async def main():
114115
# display battery indicator and time as a home screen
115116
batteryPercent = await f.get_battery_level()
116117
# select a battery fill color from the default palette based on level
117-
color = 2 if batteryPercent < 20 else 6 if batteryPercent < 50 else 9
118+
color = PaletteColors.RED if batteryPercent < 20 else PaletteColors.YELLOW if batteryPercent < 50 else PaletteColors.GREEN
118119
# specify the size of the battery indicator in the top-right
119120
batteryWidth = 150
120121
batteryHeight = 75
121122
# draw the endcap of the battery
122-
await f.display.draw_rect(640-32,40 + batteryHeight//2-8, 32, 16, 1)
123+
await f.display.draw_rect(640-32,40 + batteryHeight//2-8, 32, 16, PaletteColors.WHITE)
123124
# draw the battery outline
124-
await f.display.draw_rect_filled(640-16-batteryWidth, 40-8, batteryWidth+16, batteryHeight+16, 8, 1, 15)
125+
await f.display.draw_rect_filled(640-16-batteryWidth, 40-8, batteryWidth+16, batteryHeight+16, PaletteColors.WHITE, 1, 15)
125126
# fill the battery based on level
126127
await f.display.draw_rect(640-8-batteryWidth, 40, int(batteryWidth * 0.01 * batteryPercent), batteryHeight, color)
127128
# write the battery level
@@ -167,4 +168,6 @@ With a Frame device in range, run:
167168

168169
```sh
169170
python3 -m pytest tests/*
170-
```
171+
```
172+
173+
Note that one of the audio playback tests fails on Windows.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "frame-sdk"
7-
version = "1.1.0"
7+
version = "1.2.0"
88
authors = [{ name = "Roger Pincombe", email = "pip@betechie.com" },{ name = "Brilliant Labs", email = "info@brilliant.xyz" }]
99
description = "Python Developer SDK for Brilliant Frame glasses"
1010
readme = "readme.md"

src/frame_sdk/bluetooth.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import asyncio
22
from typing import Optional, Callable, List, Tuple, Dict, Any
3-
3+
from enum import Enum
44
from bleak import BleakClient, BleakScanner, BleakError
55

66
_FRAME_DATA_PREFIX = 1
7-
_FRAME_LONG_TEXT_PREFIX = 10
8-
_FRAME_LONG_TEXT_END_PREFIX = 11
9-
_FRAME_LONG_DATA_PREFIX = 1
10-
_FRAME_LONG_DATA_END_PREFIX = 2
117

12-
_FRAME_TAP_PREFIX = b'\x04'
13-
_FRAME_MIC_DATA_PREFIX = b'\x05'
8+
class FrameDataTypePrefixes(Enum):
9+
LONG_DATA = 0x01
10+
LONG_DATA_END = 0x02
11+
WAKE = 0x03
12+
TAP = 0x04
13+
MIC_DATA = 0x05
14+
DEBUG_PRINT = 0x06
15+
LONG_TEXT = 0x0A
16+
LONG_TEXT_END = 0x0B
17+
18+
@property
19+
def value_as_hex(self):
20+
return f'{self.value:02x}'
21+
1422

1523
class Bluetooth:
1624
"""
@@ -41,7 +49,7 @@ def __init__(self):
4149
self._ongoing_data_response: Optional[bytearray] = None
4250
self._ongoing_data_response_chunk_count: Optional[int] = None
4351
self._data_response_event: asyncio.Event = asyncio.Event()
44-
self._user_data_response_handlers: Dict[bytes, Callable[[bytes], None]] = {}
52+
self._user_data_response_handlers: Dict[FrameDataTypePrefixes, Callable[[bytes], None]] = {}
4553

4654

4755
def _disconnect_handler(self, _: Any) -> None:
@@ -57,7 +65,7 @@ async def _notification_handler(self, _: Any, data: bytearray) -> None:
5765
Args:
5866
data (bytearray): The data received from the device as raw bytes
5967
"""
60-
if data[0] == _FRAME_LONG_TEXT_PREFIX:
68+
if data[0] == FrameDataTypePrefixes.LONG_TEXT.value:
6169
# start of long printed data from prntLng() function
6270
if self._ongoing_print_response is None or self._ongoing_print_response_chunk_count is None:
6371
self._ongoing_print_response = bytearray()
@@ -71,7 +79,7 @@ async def _notification_handler(self, _: Any, data: bytearray) -> None:
7179
if len(self._ongoing_print_response) > self._max_receive_buffer:
7280
raise Exception(f"Buffered received long printed string is more than {self._max_receive_buffer} bytes")
7381

74-
elif data[0] == _FRAME_LONG_TEXT_END_PREFIX:
82+
elif data[0] == FrameDataTypePrefixes.LONG_TEXT_END.value:
7583
# end of long printed data from prntLng() function
7684
total_expected_chunk_count_as_string: str = data[1:].decode()
7785
if len(total_expected_chunk_count_as_string) > 0:
@@ -88,7 +96,7 @@ async def _notification_handler(self, _: Any, data: bytearray) -> None:
8896
print("Finished receiving long printed string: "+self._last_print_response)
8997
self._user_print_response_handler(self._last_print_response)
9098

91-
elif data[0] == _FRAME_DATA_PREFIX and data[1] == _FRAME_LONG_DATA_PREFIX:
99+
elif data[0] == _FRAME_DATA_PREFIX and data[1] == FrameDataTypePrefixes.LONG_DATA.value:
92100
# start of long raw data from frame.bluetooth.send("\001"..data)
93101
if self._ongoing_data_response is None or self._ongoing_data_response_chunk_count is None:
94102
self._ongoing_data_response = bytearray()
@@ -103,7 +111,7 @@ async def _notification_handler(self, _: Any, data: bytearray) -> None:
103111
if len(self._ongoing_data_response) > self._max_receive_buffer:
104112
raise Exception(f"Buffered received long raw data is more than {self._max_receive_buffer} bytes")
105113

106-
elif data[0] == _FRAME_DATA_PREFIX and data[1] == _FRAME_LONG_DATA_END_PREFIX:
114+
elif data[0] == _FRAME_DATA_PREFIX and data[1] == FrameDataTypePrefixes.LONG_DATA_END.value:
107115
# end of long raw data from frame.bluetooth.send("\002"..chunkCount)
108116
total_expected_chunk_count_as_string: str = data[2:].decode()
109117
if len(total_expected_chunk_count_as_string) > 0:
@@ -139,7 +147,7 @@ async def _notification_handler(self, _: Any, data: bytearray) -> None:
139147
self._print_response_event.set()
140148
self._user_print_response_handler(data.decode())
141149

142-
def register_data_response_handler(self, prefix: bytes = None, handler: Callable[[bytes], None] = None) -> None:
150+
def register_data_response_handler(self, prefix: FrameDataTypePrefixes = None, handler: Callable[[bytes], None] = None) -> None:
143151
"""Registers a data response handler which will be called when data is received from the device that starts with the specified prefix."""
144152
if handler is None:
145153
self._user_data_response_handlers.pop(prefix, None)
@@ -152,9 +160,9 @@ def register_data_response_handler(self, prefix: bytes = None, handler: Callable
152160
def call_data_response_handlers(self, data: bytes) -> None:
153161
"""Calls all data response handlers which match the received data."""
154162
for prefix, handler in self._user_data_response_handlers.items():
155-
if prefix is None or data.startswith(prefix):
163+
if prefix is None or (len(data) > 0 and data[0] == prefix.value):
156164
if handler is not None:
157-
handler(data[len(prefix):])
165+
handler(data[1:])
158166

159167
@property
160168
def print_response_handler(self) -> Callable[[str], None]:

src/frame_sdk/camera.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,24 @@
66
if TYPE_CHECKING:
77
from .frame import Frame
88

9+
from enum import Enum
10+
11+
class Quality(Enum):
12+
LOW = 10
13+
MEDIUM = 25
14+
HIGH = 50
15+
FULL = 100
16+
17+
class AutofocusType(Enum):
18+
SPOT = "SPOT"
19+
AVERAGE = "AVERAGE"
20+
CENTER_WEIGHTED = "CENTER_WEIGHTED"
21+
22+
923
class Camera:
1024
"""Helpers for working with the Frame camera."""
1125

1226
frame: "Frame" = None
13-
14-
LOW_QUALITY = 10
15-
MEDIUM_QUALITY = 25
16-
HIGH_QUALITY = 50
17-
FULL_QUALITY = 100
18-
19-
AUTOFOCUS_TYPE_SPOT = "SPOT"
20-
AUTOFOCUS_TYPE_AVERAGE = "AVERAGE"
21-
AUTOFOCUS_TYPE_CENTER_WEIGHTED = "CENTER_WEIGHTED"
2227

2328
_auto_process_photo = True
2429

@@ -38,13 +43,13 @@ def auto_process_photo(self, value: bool):
3843
self._auto_process_photo = value
3944

4045

41-
async def take_photo(self, autofocus_seconds: Optional[int] = 3, quality: int = MEDIUM_QUALITY, autofocus_type: str = AUTOFOCUS_TYPE_AVERAGE) -> bytes:
46+
async def take_photo(self, autofocus_seconds: Optional[int] = 3, quality: Quality = Quality.MEDIUM, autofocus_type: AutofocusType = AutofocusType.AVERAGE) -> bytes:
4247
"""Take a photo with the camera.
4348
4449
Args:
4550
autofocus_seconds (Optional[int]): If provided, the camera will attempt to focus for the specified number of seconds. Defaults to 3. If `None`, the camera will not attempt to focus at all.
46-
quality (int): The quality of the photo. Defaults to MEDIUM_QUALITY. May be one of LOW_QUALITY (10), MEDIUM_QUALITY (25), HIGH_QUALITY (50), or FULL_QUALITY (100).
47-
autofocus_type (str): The type of autofocus. Defaults to AUTOFOCUS_TYPE_AVERAGE. May be one of AUTOFOCUS_TYPE_SPOT, AUTOFOCUS_TYPE_AVERAGE, or AUTOFOCUS_TYPE_CENTER_WEIGHTED.
51+
quality (Quality): The quality of the photo. Defaults to Quality.MEDIUM.
52+
autofocus_type (AutofocusType): The type of autofocus. Defaults to AutofocusType.AVERAGE.
4853
4954
Returns:
5055
bytes: The photo as a byte array.
@@ -57,7 +62,12 @@ async def take_photo(self, autofocus_seconds: Optional[int] = 3, quality: int =
5762
await self.frame.run_lua("frame.camera.wake()", checked=True)
5863
self.is_awake = True
5964

60-
await self.frame.bluetooth.send_lua(f"cameraCaptureAndSend({quality},{autofocus_seconds or 'nil'},{autofocus_type})")
65+
if type(quality) == int:
66+
quality = Quality(quality)
67+
if type(autofocus_type) == int:
68+
autofocus_type = AutofocusType(autofocus_type)
69+
70+
await self.frame.bluetooth.send_lua(f"cameraCaptureAndSend({quality.value},{autofocus_seconds or 'nil'},'{autofocus_type.value}')")
6171
image_buffer = await self.frame.bluetooth.wait_for_data()
6272

6373
if image_buffer is None or len(image_buffer) == 0:
@@ -67,26 +77,26 @@ async def take_photo(self, autofocus_seconds: Optional[int] = 3, quality: int =
6777
image_buffer = self.process_photo(image_buffer, autofocus_type)
6878
return image_buffer
6979

70-
async def save_photo(self, filename: str, autofocus_seconds: Optional[int] = 3, quality: int = MEDIUM_QUALITY, autofocus_type: str = AUTOFOCUS_TYPE_AVERAGE):
80+
async def save_photo(self, filename: str, autofocus_seconds: Optional[int] = 3, quality: Quality = Quality.MEDIUM, autofocus_type: AutofocusType = AutofocusType.AVERAGE):
7181
"""Save a photo to a file.
7282
7383
Args:
7484
filename (str): The name of the file to save the photo. The file will always be saved as a jpeg image regardless of the file extension.
7585
autofocus_seconds (Optional[int]): If provided, the camera will attempt to focus for the specified number of seconds. Defaults to 3. If `None`, the camera will not attempt to focus at all.
76-
quality (int): The quality of the photo. Defaults to MEDIUM_QUALITY. May be one of LOW_QUALITY (10), MEDIUM_QUALITY (25), HIGH_QUALITY (50), or FULL_QUALITY (100).
77-
autofocus_type (str): The type of autofocus. Defaults to AUTOFOCUS_TYPE_AVERAGE. May be one of AUTOFOCUS_TYPE_SPOT, AUTOFOCUS_TYPE_AVERAGE, or AUTOFOCUS_TYPE_CENTER_WEIGHTED.
86+
quality (Quality): The quality of the photo. Defaults to Quality.MEDIUM.
87+
autofocus_type (AutofocusType): The type of autofocus. Defaults to AutofocusType.AVERAGE.
7888
"""
7989
image_buffer = await self.take_photo(autofocus_seconds, quality, autofocus_type)
8090

8191
with open(filename, "wb") as f:
8292
f.write(image_buffer)
8393

84-
def process_photo(self, image_buffer: bytes, autofocus_type: str) -> bytes:
94+
def process_photo(self, image_buffer: bytes, autofocus_type: AutofocusType) -> bytes:
8595
"""Process a photo to correct rotation and add metadata.
8696
8797
Args:
8898
image_buffer (bytes): The photo as a byte array.
89-
autofocus_type (str): The type of autofocus that was used to capture the photo. Should be one of AUTOFOCUS_TYPE_SPOT, AUTOFOCUS_TYPE_AVERAGE, or AUTOFOCUS_TYPE_CENTER_WEIGHTED.
99+
autofocus_type (AutofocusType): The type of autofocus that was used to capture the photo.
90100
91101
Returns:
92102
bytes: The processed photo as a byte array.
@@ -96,11 +106,11 @@ def process_photo(self, image_buffer: bytes, autofocus_type: str) -> bytes:
96106
image.make = "Brilliant Labs"
97107
image.model = "Frame"
98108
image.software = "Frame Python SDK"
99-
if autofocus_type == self.AUTOFOCUS_TYPE_AVERAGE:
109+
if autofocus_type == AutofocusType.AVERAGE:
100110
image.metering_mode = 1
101-
elif autofocus_type == self.AUTOFOCUS_TYPE_CENTER_WEIGHTED:
111+
elif autofocus_type == AutofocusType.CENTER_WEIGHTED:
102112
image.metering_mode = 2
103-
elif autofocus_type == self.AUTOFOCUS_TYPE_SPOT:
113+
elif autofocus_type == AutofocusType.SPOT:
104114
image.metering_mode = 3
105115
image.datetime_original = datetime.now().strftime("%Y:%m:%d %H:%M:%S")
106116
return image.get_file()

0 commit comments

Comments
 (0)