dominus-sdk-python-dev 2.3.0__py3-none-any.whl → 2.5.0__py3-none-any.whl

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.
dominus/__init__.py CHANGED
@@ -8,6 +8,10 @@ Ultra-Flat API:
8
8
  value = await dominus.get("DB_URL")
9
9
  await dominus.upsert("KEY", "value")
10
10
 
11
+ # Secrets namespace
12
+ value = await dominus.secrets.get("DB_URL")
13
+ await dominus.secrets.upsert("KEY", "value")
14
+
11
15
  # Database (namespace-based)
12
16
  tables = await dominus.db.tables()
13
17
  rows = await dominus.db.query("users", filters={"status": "active"})
@@ -23,6 +27,16 @@ Ultra-Flat API:
23
27
  # Files (namespace-based)
24
28
  result = await dominus.files.upload(data=file_bytes, filename="report.pdf", category="reports")
25
29
 
30
+ # Redis caching
31
+ await dominus.redis.set("key", "value", ttl=3600)
32
+ value = await dominus.redis.get("key")
33
+
34
+ # Logging
35
+ await dominus.logs.info("Operation complete", {"user_id": user_id})
36
+
37
+ # Email
38
+ await dominus.courier.send("welcome", to="user@example.com", from_email="hello@app.com", model={})
39
+
26
40
  # Secure table access with audit logging
27
41
  rows = await dominus.db_secure.query("patients", context=SecureAccessContext(
28
42
  reason="Reviewing chart",
@@ -35,11 +49,14 @@ Ultra-Flat API:
35
49
  # Portal authentication
36
50
  session = await dominus.portal.login("user@example.com", "password", "tenant-id")
37
51
 
38
- # Open DSN
52
+ # Open DSN and raw SQL
39
53
  dsn = await dominus.open.dsn()
54
+ result = await dominus.open.execute("SELECT * FROM users WHERE id = $1", {"1": user_id})
40
55
 
41
56
  # Health
42
57
  status = await dominus.health.check()
58
+ await dominus.health.ping()
59
+ await dominus.health.warmup()
43
60
 
44
61
  Backward Compatible APIs:
45
62
  # String-based API
@@ -76,11 +93,45 @@ from .namespaces.files import FilesNamespace
76
93
  from .namespaces.portal import PortalNamespace
77
94
  from .namespaces.admin import AdminNamespace
78
95
  from .namespaces.secure import SecureNamespace, SecureAccessContext
96
+ from .namespaces.secrets import SecretsNamespace
97
+ from .namespaces.redis import RedisNamespace
98
+ from .namespaces.logs import LogsNamespace
99
+ from .namespaces.courier import CourierNamespace
100
+ from .namespaces.health import HealthNamespace
101
+ from .namespaces.open import OpenNamespace
102
+
103
+ # Export Oracle namespace for speech-to-text
104
+ from .namespaces.oracle import (
105
+ OracleNamespace,
106
+ OracleSession,
107
+ OracleSessionOptions,
108
+ VADState,
109
+ )
110
+
111
+ # Export cache and resilience utilities
112
+ from .helpers.cache import (
113
+ dominus_cache,
114
+ CircuitBreaker,
115
+ DominusCache,
116
+ orchestrator_circuit_breaker,
117
+ exponential_backoff_with_jitter,
118
+ )
79
119
 
80
- # Export cache utilities
81
- from .helpers.cache import dominus_cache
120
+ # Export error classes
121
+ from .errors import (
122
+ DominusError,
123
+ AuthenticationError,
124
+ AuthorizationError,
125
+ NotFoundError,
126
+ ValidationError,
127
+ ConflictError,
128
+ ServiceError,
129
+ SecureTableError,
130
+ ConnectionError as DominusConnectionError,
131
+ TimeoutError as DominusTimeoutError,
132
+ )
82
133
 
83
- __version__ = "2.2.0"
134
+ __version__ = "2.5.0"
84
135
  __all__ = [
85
136
  # Main SDK instance
86
137
  "dominus",
@@ -103,6 +154,32 @@ __all__ = [
103
154
  "AdminNamespace",
104
155
  "SecureNamespace",
105
156
  "SecureAccessContext",
106
- # Cache utilities
157
+ "SecretsNamespace",
158
+ "RedisNamespace",
159
+ "LogsNamespace",
160
+ "CourierNamespace",
161
+ "HealthNamespace",
162
+ "OpenNamespace",
163
+ # Oracle namespace for speech-to-text
164
+ "OracleNamespace",
165
+ "OracleSession",
166
+ "OracleSessionOptions",
167
+ "VADState",
168
+ # Cache and resilience utilities
107
169
  "dominus_cache",
170
+ "CircuitBreaker",
171
+ "DominusCache",
172
+ "orchestrator_circuit_breaker",
173
+ "exponential_backoff_with_jitter",
174
+ # Error classes
175
+ "DominusError",
176
+ "AuthenticationError",
177
+ "AuthorizationError",
178
+ "NotFoundError",
179
+ "ValidationError",
180
+ "ConflictError",
181
+ "ServiceError",
182
+ "SecureTableError",
183
+ "DominusConnectionError",
184
+ "DominusTimeoutError",
108
185
  ]
dominus/helpers/cache.py CHANGED
@@ -1,7 +1,12 @@
1
1
  """
2
2
  Internal cache with automatic encryption and circuit breaker.
3
3
 
4
- NOT exposed to SDK users - internal use only.
4
+ Exports:
5
+ - CircuitBreaker: Circuit breaker class for resilience
6
+ - DominusCache: Encrypted cache class
7
+ - dominus_cache: Singleton cache instance
8
+ - orchestrator_circuit_breaker: Circuit breaker for orchestrator
9
+ - exponential_backoff_with_jitter: Backoff calculation utility
5
10
  """
6
11
  import time
7
12
  import json
@@ -181,12 +186,15 @@ class DominusCache:
181
186
  return count
182
187
 
183
188
 
184
- # Internal singletons - NOT exported to users
189
+ # Singleton instances
185
190
  dominus_cache = DominusCache(default_ttl=300)
186
191
 
187
- # Circuit breakers for different services (prevents retry storms)
188
- sovereign_circuit_breaker = CircuitBreaker(
192
+ # Circuit breaker for orchestrator (prevents retry storms)
193
+ orchestrator_circuit_breaker = CircuitBreaker(
189
194
  failure_threshold=5, # Open after 5 consecutive failures
190
195
  recovery_timeout=30.0, # Try again after 30 seconds
191
196
  half_open_max_calls=1 # Allow 1 test call in half-open state
192
197
  )
198
+
199
+ # Backward compatibility alias
200
+ sovereign_circuit_breaker = orchestrator_circuit_breaker
@@ -0,0 +1,120 @@
1
+ """
2
+ Oracle Namespace - Real-time streaming speech-to-text.
3
+
4
+ Provides WebSocket-based streaming transcription via Deepgram,
5
+ with built-in VAD (Voice Activity Detection) for cost optimization.
6
+
7
+ Key features:
8
+ - Automatic microphone capture and 16kHz resampling
9
+ - VAD gating: only sends audio when speech is detected
10
+ - 4-state VAD machine: IDLE -> ARMED -> SPEAKING -> TRAILING
11
+ - Pre-roll buffer captures word onsets
12
+ - Ping/pong keepalive during IDLE
13
+ - NO send_audio() exposed - VAD handles everything
14
+
15
+ Usage:
16
+ session = dominus.oracle.create_session(user_jwt)
17
+
18
+ session.on_ready = lambda: set_listening(True)
19
+ session.on_interim = lambda text: set_live_transcript(text)
20
+ session.on_utterance = lambda text: send_to_curator(text)
21
+ session.on_vad_state_change = lambda state: set_mic_state(state)
22
+ session.on_error = lambda error: show_error(error)
23
+
24
+ await session.start()
25
+ # ... user speaks, transcripts flow back ...
26
+ await session.stop()
27
+ """
28
+
29
+ from typing import Optional, TYPE_CHECKING
30
+
31
+ from .types import (
32
+ VADState,
33
+ OracleSessionOptions,
34
+ ResolvedOracleSessionOptions,
35
+ DEFAULT_OPTIONS,
36
+ AUDIO_CONFIG,
37
+ )
38
+ from .session import OracleSession
39
+
40
+ if TYPE_CHECKING:
41
+ from ...start import Dominus
42
+
43
+ # Re-export public types
44
+ __all__ = [
45
+ "OracleNamespace",
46
+ "OracleSession",
47
+ "OracleSessionOptions",
48
+ "VADState",
49
+ ]
50
+
51
+
52
+ class OracleNamespace:
53
+ """
54
+ OracleNamespace - Factory for creating streaming transcription sessions.
55
+
56
+ The Oracle namespace provides a simple API for real-time speech-to-text:
57
+ - create_session() creates a new transcription session
58
+ - Sessions handle mic capture, VAD, WebSocket, and transcripts internally
59
+ - NO raw audio access - VAD is mandatory for cost control
60
+ """
61
+
62
+ def __init__(self, client: "Dominus"):
63
+ from ...config.endpoints import BASE_URL
64
+ self._base_url = BASE_URL
65
+ self._client = client
66
+
67
+ def create_session(
68
+ self,
69
+ user_token: str,
70
+ options: Optional[OracleSessionOptions] = None
71
+ ) -> OracleSession:
72
+ """
73
+ Create a streaming transcription session.
74
+
75
+ The session handles everything internally:
76
+ - Microphone access and audio capture
77
+ - Resampling to 16kHz mono PCM
78
+ - VAD gating (only sends speech, not silence)
79
+ - WebSocket connection to Oracle
80
+ - Reconnection on connection loss
81
+
82
+ Args:
83
+ user_token: User JWT from portal.login()
84
+ options: Optional configuration overrides
85
+
86
+ Returns:
87
+ OracleSession ready to start()
88
+
89
+ Example:
90
+ session = dominus.oracle.create_session(user_jwt, OracleSessionOptions(
91
+ preroll_ms=320, # Capture 320ms before speech
92
+ postroll_ms=400, # Continue 400ms after speech
93
+ armed_confirm_ms=80, # Require 80ms to confirm speech
94
+ ))
95
+
96
+ session.on_utterance = lambda text: send_to_curator(text)
97
+
98
+ await session.start()
99
+ """
100
+ # Merge options with defaults
101
+ if options:
102
+ resolved_options = ResolvedOracleSessionOptions(
103
+ preroll_ms=options.preroll_ms if options.preroll_ms != DEFAULT_OPTIONS.preroll_ms else DEFAULT_OPTIONS.preroll_ms,
104
+ postroll_ms=options.postroll_ms if options.postroll_ms != DEFAULT_OPTIONS.postroll_ms else DEFAULT_OPTIONS.postroll_ms,
105
+ armed_confirm_ms=options.armed_confirm_ms if options.armed_confirm_ms != DEFAULT_OPTIONS.armed_confirm_ms else DEFAULT_OPTIONS.armed_confirm_ms,
106
+ vad_threshold=options.vad_threshold if options.vad_threshold != DEFAULT_OPTIONS.vad_threshold else DEFAULT_OPTIONS.vad_threshold,
107
+ energy_threshold=options.energy_threshold if options.energy_threshold != DEFAULT_OPTIONS.energy_threshold else DEFAULT_OPTIONS.energy_threshold,
108
+ ping_interval_ms=options.ping_interval_ms if options.ping_interval_ms != DEFAULT_OPTIONS.ping_interval_ms else DEFAULT_OPTIONS.ping_interval_ms,
109
+ )
110
+ else:
111
+ resolved_options = ResolvedOracleSessionOptions(
112
+ preroll_ms=DEFAULT_OPTIONS.preroll_ms,
113
+ postroll_ms=DEFAULT_OPTIONS.postroll_ms,
114
+ armed_confirm_ms=DEFAULT_OPTIONS.armed_confirm_ms,
115
+ vad_threshold=DEFAULT_OPTIONS.vad_threshold,
116
+ energy_threshold=DEFAULT_OPTIONS.energy_threshold,
117
+ ping_interval_ms=DEFAULT_OPTIONS.ping_interval_ms,
118
+ )
119
+
120
+ return OracleSession(self._base_url, user_token, resolved_options)
@@ -0,0 +1,382 @@
1
+ """
2
+ AudioCapture - Microphone capture and resampling (INTERNAL)
3
+
4
+ Handles:
5
+ - Microphone access via sounddevice or pyaudio
6
+ - Resampling to 16kHz mono if needed
7
+ - Output 20ms frames (640 bytes PCM16)
8
+
9
+ This module is INTERNAL and should NOT be exported publicly.
10
+ """
11
+
12
+ import asyncio
13
+ import struct
14
+ import math
15
+ from typing import Callable, Optional, List
16
+ from dataclasses import dataclass
17
+
18
+ from .types import AUDIO_CONFIG
19
+
20
+ # Try to import audio libraries
21
+ try:
22
+ import sounddevice as sd
23
+ import numpy as np
24
+ SOUNDDEVICE_AVAILABLE = True
25
+ except ImportError:
26
+ SOUNDDEVICE_AVAILABLE = False
27
+ sd = None
28
+ np = None
29
+
30
+ try:
31
+ import pyaudio
32
+ PYAUDIO_AVAILABLE = True
33
+ except ImportError:
34
+ PYAUDIO_AVAILABLE = False
35
+ pyaudio = None
36
+
37
+
38
+ def resample(input_data: List[float], from_rate: int, to_rate: int) -> List[float]:
39
+ """
40
+ Resample audio data using linear interpolation.
41
+
42
+ Args:
43
+ input_data: Input samples as floats
44
+ from_rate: Source sample rate
45
+ to_rate: Target sample rate
46
+
47
+ Returns:
48
+ Resampled audio data
49
+ """
50
+ if from_rate == to_rate:
51
+ return input_data
52
+
53
+ ratio = from_rate / to_rate
54
+ output_length = math.ceil(len(input_data) / ratio)
55
+ output = []
56
+
57
+ for i in range(output_length):
58
+ src_index = i * ratio
59
+ src_floor = int(src_index)
60
+ src_ceil = min(src_floor + 1, len(input_data) - 1)
61
+ t = src_index - src_floor
62
+
63
+ # Linear interpolation
64
+ value = input_data[src_floor] * (1 - t) + input_data[src_ceil] * t
65
+ output.append(value)
66
+
67
+ return output
68
+
69
+
70
+ def float_to_pcm16(samples: List[float]) -> bytes:
71
+ """
72
+ Convert float samples [-1, 1] to PCM16 bytes.
73
+
74
+ Args:
75
+ samples: Float samples in range [-1, 1]
76
+
77
+ Returns:
78
+ PCM16 bytes (little-endian)
79
+ """
80
+ pcm_values = []
81
+ for s in samples:
82
+ # Clamp to [-1, 1]
83
+ s = max(-1.0, min(1.0, s))
84
+ # Convert to int16
85
+ if s < 0:
86
+ pcm_values.append(int(s * 0x8000))
87
+ else:
88
+ pcm_values.append(int(s * 0x7FFF))
89
+
90
+ return struct.pack(f"<{len(pcm_values)}h", *pcm_values)
91
+
92
+
93
+ class SoundDeviceCapture:
94
+ """
95
+ Audio capture using sounddevice library.
96
+ Preferred for cross-platform compatibility.
97
+ """
98
+
99
+ def __init__(self):
100
+ if not SOUNDDEVICE_AVAILABLE:
101
+ raise ImportError(
102
+ "sounddevice package is required for audio capture. "
103
+ "Install with: pip install sounddevice numpy"
104
+ )
105
+
106
+ self._stream: Optional[sd.InputStream] = None
107
+ self._buffer: List[float] = []
108
+ self._is_capturing = False
109
+ self._callback_queue: asyncio.Queue = None
110
+ self._process_task: Optional[asyncio.Task] = None
111
+
112
+ self.on_frame: Optional[Callable[[bytes], None]] = None
113
+
114
+ async def start(self) -> None:
115
+ """Start audio capture from microphone."""
116
+ if self._is_capturing:
117
+ return
118
+
119
+ self._callback_queue = asyncio.Queue()
120
+
121
+ def audio_callback(indata, frames, time_info, status):
122
+ if status:
123
+ print(f"[OracleSDK] Audio status: {status}")
124
+
125
+ # Convert numpy array to list of floats
126
+ mono_data = indata[:, 0].tolist() if indata.ndim > 1 else indata.flatten().tolist()
127
+
128
+ # Put data in queue for async processing
129
+ try:
130
+ self._callback_queue.put_nowait(mono_data)
131
+ except asyncio.QueueFull:
132
+ pass # Drop frames if queue is full
133
+
134
+ # Open stream
135
+ self._stream = sd.InputStream(
136
+ samplerate=AUDIO_CONFIG.SAMPLE_RATE,
137
+ channels=AUDIO_CONFIG.CHANNELS,
138
+ dtype='float32',
139
+ blocksize=1024, # Good balance of latency vs efficiency
140
+ callback=audio_callback,
141
+ )
142
+ self._stream.start()
143
+ self._is_capturing = True
144
+
145
+ # Start processing task
146
+ self._process_task = asyncio.create_task(self._process_audio())
147
+
148
+ async def stop(self) -> None:
149
+ """Stop audio capture."""
150
+ if not self._is_capturing:
151
+ return
152
+
153
+ self._is_capturing = False
154
+
155
+ if self._process_task:
156
+ self._process_task.cancel()
157
+ try:
158
+ await self._process_task
159
+ except asyncio.CancelledError:
160
+ pass
161
+ self._process_task = None
162
+
163
+ if self._stream:
164
+ self._stream.stop()
165
+ self._stream.close()
166
+ self._stream = None
167
+
168
+ self._buffer = []
169
+
170
+ async def _process_audio(self) -> None:
171
+ """Process audio from queue and emit frames."""
172
+ while self._is_capturing:
173
+ try:
174
+ # Get audio data from queue
175
+ audio_data = await asyncio.wait_for(
176
+ self._callback_queue.get(),
177
+ timeout=0.1
178
+ )
179
+
180
+ # Append to buffer
181
+ self._buffer.extend(audio_data)
182
+
183
+ # Extract complete frames
184
+ while len(self._buffer) >= AUDIO_CONFIG.SAMPLES_PER_FRAME:
185
+ frame_samples = self._buffer[:AUDIO_CONFIG.SAMPLES_PER_FRAME]
186
+ self._buffer = self._buffer[AUDIO_CONFIG.SAMPLES_PER_FRAME:]
187
+
188
+ # Convert to PCM16
189
+ pcm_frame = float_to_pcm16(frame_samples)
190
+
191
+ if self.on_frame:
192
+ self.on_frame(pcm_frame)
193
+
194
+ except asyncio.TimeoutError:
195
+ continue
196
+ except asyncio.CancelledError:
197
+ break
198
+
199
+
200
+ class PyAudioCapture:
201
+ """
202
+ Audio capture using PyAudio library.
203
+ Fallback for environments where sounddevice doesn't work.
204
+ """
205
+
206
+ def __init__(self):
207
+ if not PYAUDIO_AVAILABLE:
208
+ raise ImportError(
209
+ "pyaudio package is required for audio capture. "
210
+ "Install with: pip install pyaudio"
211
+ )
212
+
213
+ self._pa: Optional[pyaudio.PyAudio] = None
214
+ self._stream = None
215
+ self._buffer: List[float] = []
216
+ self._is_capturing = False
217
+ self._callback_queue: asyncio.Queue = None
218
+ self._process_task: Optional[asyncio.Task] = None
219
+
220
+ self.on_frame: Optional[Callable[[bytes], None]] = None
221
+
222
+ async def start(self) -> None:
223
+ """Start audio capture from microphone."""
224
+ if self._is_capturing:
225
+ return
226
+
227
+ self._pa = pyaudio.PyAudio()
228
+ self._callback_queue = asyncio.Queue()
229
+
230
+ def audio_callback(in_data, frame_count, time_info, status):
231
+ # Convert bytes to float samples
232
+ num_samples = len(in_data) // 2
233
+ samples = struct.unpack(f"<{num_samples}h", in_data)
234
+ # Normalize to [-1, 1]
235
+ float_samples = [s / 32768.0 for s in samples]
236
+
237
+ try:
238
+ self._callback_queue.put_nowait(float_samples)
239
+ except asyncio.QueueFull:
240
+ pass
241
+
242
+ return (None, pyaudio.paContinue)
243
+
244
+ # Open stream
245
+ self._stream = self._pa.open(
246
+ format=pyaudio.paInt16,
247
+ channels=AUDIO_CONFIG.CHANNELS,
248
+ rate=AUDIO_CONFIG.SAMPLE_RATE,
249
+ input=True,
250
+ frames_per_buffer=1024,
251
+ stream_callback=audio_callback,
252
+ )
253
+ self._stream.start_stream()
254
+ self._is_capturing = True
255
+
256
+ # Start processing task
257
+ self._process_task = asyncio.create_task(self._process_audio())
258
+
259
+ async def stop(self) -> None:
260
+ """Stop audio capture."""
261
+ if not self._is_capturing:
262
+ return
263
+
264
+ self._is_capturing = False
265
+
266
+ if self._process_task:
267
+ self._process_task.cancel()
268
+ try:
269
+ await self._process_task
270
+ except asyncio.CancelledError:
271
+ pass
272
+ self._process_task = None
273
+
274
+ if self._stream:
275
+ self._stream.stop_stream()
276
+ self._stream.close()
277
+ self._stream = None
278
+
279
+ if self._pa:
280
+ self._pa.terminate()
281
+ self._pa = None
282
+
283
+ self._buffer = []
284
+
285
+ async def _process_audio(self) -> None:
286
+ """Process audio from queue and emit frames."""
287
+ while self._is_capturing:
288
+ try:
289
+ audio_data = await asyncio.wait_for(
290
+ self._callback_queue.get(),
291
+ timeout=0.1
292
+ )
293
+
294
+ self._buffer.extend(audio_data)
295
+
296
+ while len(self._buffer) >= AUDIO_CONFIG.SAMPLES_PER_FRAME:
297
+ frame_samples = self._buffer[:AUDIO_CONFIG.SAMPLES_PER_FRAME]
298
+ self._buffer = self._buffer[AUDIO_CONFIG.SAMPLES_PER_FRAME:]
299
+
300
+ pcm_frame = float_to_pcm16(frame_samples)
301
+
302
+ if self.on_frame:
303
+ self.on_frame(pcm_frame)
304
+
305
+ except asyncio.TimeoutError:
306
+ continue
307
+ except asyncio.CancelledError:
308
+ break
309
+
310
+
311
+ class AudioCapture:
312
+ """
313
+ AudioCapture - Main class for microphone capture.
314
+
315
+ Automatically uses sounddevice if available, falls back to PyAudio.
316
+ Outputs 20ms frames (640 bytes PCM16) at 16kHz mono.
317
+ """
318
+
319
+ def __init__(self):
320
+ self._capture = None
321
+ self._is_capturing = False
322
+
323
+ # Callback for each audio frame
324
+ self.on_frame: Optional[Callable[[bytes], None]] = None
325
+
326
+ @property
327
+ def is_capturing(self) -> bool:
328
+ """Check if currently capturing audio."""
329
+ return self._is_capturing
330
+
331
+ async def start(self) -> None:
332
+ """
333
+ Start audio capture from microphone.
334
+
335
+ Raises:
336
+ ImportError: If no audio library is available
337
+ Exception: If microphone access fails
338
+ """
339
+ if self._is_capturing:
340
+ return
341
+
342
+ # Choose capture method based on available libraries
343
+ if SOUNDDEVICE_AVAILABLE:
344
+ try:
345
+ self._capture = SoundDeviceCapture()
346
+ await self._capture.start()
347
+ except Exception as e:
348
+ print(f"[OracleSDK] sounddevice failed, trying PyAudio: {e}")
349
+ if PYAUDIO_AVAILABLE:
350
+ self._capture = PyAudioCapture()
351
+ await self._capture.start()
352
+ else:
353
+ raise
354
+ elif PYAUDIO_AVAILABLE:
355
+ self._capture = PyAudioCapture()
356
+ await self._capture.start()
357
+ else:
358
+ raise ImportError(
359
+ "Audio capture requires either sounddevice or pyaudio. "
360
+ "Install with: pip install sounddevice numpy OR pip install pyaudio"
361
+ )
362
+
363
+ # Wire up frame callback
364
+ self._capture.on_frame = lambda pcm_frame: (
365
+ self.on_frame(pcm_frame) if self.on_frame else None
366
+ )
367
+
368
+ self._is_capturing = True
369
+
370
+ async def stop(self) -> None:
371
+ """Stop audio capture."""
372
+ if not self._is_capturing or not self._capture:
373
+ return
374
+
375
+ await self._capture.stop()
376
+ self._capture = None
377
+ self._is_capturing = False
378
+
379
+ def dispose(self) -> None:
380
+ """Clean up resources."""
381
+ asyncio.create_task(self.stop())
382
+ self.on_frame = None