simo 2.11.4__py3-none-any.whl → 3.0.4__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.
Potentially problematic release.
This version of simo might be problematic. Click here for more details.
- simo/__pycache__/settings.cpython-312.pyc +0 -0
- simo/asgi.py +25 -6
- simo/automation/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/automation/controllers.py +18 -2
- simo/automation/forms.py +15 -24
- simo/automation/gateways.py +32 -16
- simo/core/__pycache__/admin.cpython-312.pyc +0 -0
- simo/core/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/core/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/core/__pycache__/forms.cpython-312.pyc +0 -0
- simo/core/__pycache__/models.cpython-312.pyc +0 -0
- simo/core/__pycache__/serializers.cpython-312.pyc +0 -0
- simo/core/__pycache__/signal_receivers.cpython-312.pyc +0 -0
- simo/core/__pycache__/tasks.cpython-312.pyc +0 -0
- simo/core/admin.py +5 -4
- simo/core/base_types.py +191 -18
- simo/core/controllers.py +259 -26
- simo/core/forms.py +10 -2
- simo/core/management/_hub_template/hub/nginx.conf +23 -50
- simo/core/management/_hub_template/hub/supervisor.conf +15 -0
- simo/core/mcp.py +154 -0
- simo/core/migrations/0051_instance_ai_memory.py +18 -0
- simo/core/migrations/__pycache__/0051_instance_ai_memory.cpython-312.pyc +0 -0
- simo/core/models.py +3 -0
- simo/core/serializers.py +120 -0
- simo/core/signal_receivers.py +1 -1
- simo/core/tasks.py +1 -3
- simo/core/utils/__pycache__/type_constants.cpython-312.pyc +0 -0
- simo/core/utils/type_constants.py +78 -17
- simo/fleet/__pycache__/admin.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/gateways.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
- simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
- simo/fleet/admin.py +5 -1
- simo/fleet/api.py +2 -27
- simo/fleet/base_types.py +35 -4
- simo/fleet/controllers.py +162 -156
- simo/fleet/forms.py +58 -88
- simo/fleet/gateways.py +8 -15
- simo/fleet/migrations/0055_colonel_is_vo_active_colonel_last_wake_and_more.py +28 -0
- simo/fleet/migrations/0056_delete_customdalidevice.py +16 -0
- simo/fleet/migrations/__pycache__/0055_colonel_is_vo_active_colonel_last_wake_and_more.cpython-312.pyc +0 -0
- simo/fleet/migrations/__pycache__/0056_delete_customdalidevice.cpython-312.pyc +0 -0
- simo/fleet/models.py +13 -72
- simo/fleet/serializers.py +1 -48
- simo/fleet/socket_consumers.py +100 -39
- simo/fleet/tasks.py +2 -22
- simo/fleet/voice_assistant.py +903 -0
- simo/generic/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/generic/__pycache__/gateways.cpython-312.pyc +0 -0
- simo/generic/base_types.py +70 -10
- simo/generic/controllers.py +104 -17
- simo/generic/gateways.py +10 -10
- simo/mcp_server/__init__.py +0 -0
- simo/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
- simo/mcp_server/__pycache__/admin.cpython-312.pyc +0 -0
- simo/mcp_server/__pycache__/models.cpython-312.pyc +0 -0
- simo/mcp_server/admin.py +18 -0
- simo/mcp_server/app.py +4 -0
- simo/mcp_server/auth.py +34 -0
- simo/mcp_server/dummy.py +22 -0
- simo/mcp_server/migrations/0001_initial.py +30 -0
- simo/mcp_server/migrations/0002_alter_instanceaccesstoken_date_expired.py +18 -0
- simo/mcp_server/migrations/0003_instanceaccesstoken_issuer.py +18 -0
- simo/mcp_server/migrations/__init__.py +0 -0
- simo/mcp_server/migrations/__pycache__/0001_initial.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/0002_alter_instanceaccesstoken_date_expired.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/0003_instanceaccesstoken_issuer.cpython-312.pyc +0 -0
- simo/mcp_server/migrations/__pycache__/__init__.cpython-312.pyc +0 -0
- simo/mcp_server/models.py +27 -0
- simo/mcp_server/server.py +60 -0
- simo/mcp_server/tasks.py +19 -0
- simo/multimedia/__pycache__/base_types.cpython-312.pyc +0 -0
- simo/multimedia/__pycache__/controllers.cpython-312.pyc +0 -0
- simo/multimedia/base_types.py +29 -4
- simo/multimedia/controllers.py +66 -19
- simo/settings.py +1 -0
- simo/users/__pycache__/utils.cpython-312.pyc +0 -0
- simo/users/utils.py +10 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/METADATA +11 -4
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/RECORD +90 -64
- simo/fleet/custom_dali_operations.py +0 -287
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/WHEEL +0 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/entry_points.txt +0 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/licenses/LICENSE.md +0 -0
- {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
import inspect
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
import websockets
|
|
11
|
+
import lameenc
|
|
12
|
+
from pydub import AudioSegment
|
|
13
|
+
from django.db import transaction
|
|
14
|
+
from django.utils import timezone
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from asgiref.sync import sync_to_async
|
|
17
|
+
|
|
18
|
+
from simo.conf import dynamic_settings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VoiceAssistantSession:
|
|
22
|
+
"""Manages a single Sentinel voice session for a connected Colonel.
|
|
23
|
+
|
|
24
|
+
- Buffers PCM from device, finalizes utterance on VAD-like quiet.
|
|
25
|
+
- Encodes PCM→MP3, calls Website WS, receives MP3 reply.
|
|
26
|
+
- Decodes MP3→PCM and streams to device paced.
|
|
27
|
+
- Manages `is_vo_active` lifecycle and Website start/finish HTTP hooks.
|
|
28
|
+
- Cloud traffic is gated until arbitration grants winner status.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
INACTIVITY_MS = 800
|
|
32
|
+
MAX_UTTERANCE_SEC = 20
|
|
33
|
+
PLAY_CHUNK_BYTES = 1024
|
|
34
|
+
PLAY_CHUNK_INTERVAL = 0.032
|
|
35
|
+
FOLLOWUP_SEC = 15
|
|
36
|
+
CLOUD_RESPONSE_TIMEOUT_SEC = 60
|
|
37
|
+
|
|
38
|
+
def __init__(self, consumer):
|
|
39
|
+
self.c = consumer
|
|
40
|
+
self.active = False
|
|
41
|
+
self.awaiting_response = False
|
|
42
|
+
self.playing = False
|
|
43
|
+
self._end_after_playback = False
|
|
44
|
+
self.capture_buf = bytearray()
|
|
45
|
+
self.last_chunk_ts = 0.0
|
|
46
|
+
self.last_rx_audio_ts = 0.0
|
|
47
|
+
self.last_tx_audio_ts = 0.0
|
|
48
|
+
self.started_ts = None
|
|
49
|
+
self.mcp_token = None
|
|
50
|
+
self._finalizer_task = None
|
|
51
|
+
self._cloud_task = None
|
|
52
|
+
self._play_task = None
|
|
53
|
+
self._followup_task = None
|
|
54
|
+
self.voice = 'male'
|
|
55
|
+
self.zone = None
|
|
56
|
+
self._cloud_gate = asyncio.Event()
|
|
57
|
+
self._start_session_notified = False
|
|
58
|
+
self._start_session_inflight = False
|
|
59
|
+
self._prewarm_requested = False
|
|
60
|
+
self._idle_task = asyncio.create_task(self._idle_watchdog())
|
|
61
|
+
self._utterance_task = asyncio.create_task(self._utterance_watchdog())
|
|
62
|
+
|
|
63
|
+
async def start_if_needed(self):
|
|
64
|
+
if self.active:
|
|
65
|
+
return
|
|
66
|
+
self.active = True
|
|
67
|
+
self.started_ts = time.time()
|
|
68
|
+
# Ensure a fresh session starts gated until arbitration grants winner
|
|
69
|
+
try:
|
|
70
|
+
self._cloud_gate.clear()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
# is_vo_active will be set by arbitration via open_as_winner
|
|
74
|
+
|
|
75
|
+
async def on_audio_chunk(self, payload: bytes):
|
|
76
|
+
if self.playing or self.awaiting_response:
|
|
77
|
+
return
|
|
78
|
+
await self.start_if_needed()
|
|
79
|
+
if not getattr(self, '_rx_started', False):
|
|
80
|
+
self._rx_started = True
|
|
81
|
+
self._rx_start_ts = time.time()
|
|
82
|
+
print("VA RX START (device→hub)")
|
|
83
|
+
self.capture_buf.extend(payload)
|
|
84
|
+
self.last_chunk_ts = time.time()
|
|
85
|
+
self.last_rx_audio_ts = self.last_chunk_ts
|
|
86
|
+
if len(self.capture_buf) > 2 * 16000 * self.MAX_UTTERANCE_SEC:
|
|
87
|
+
await self._finalize_utterance()
|
|
88
|
+
return
|
|
89
|
+
if not self._finalizer_task or self._finalizer_task.done():
|
|
90
|
+
self._finalizer_task = asyncio.create_task(self._finalizer_loop())
|
|
91
|
+
|
|
92
|
+
async def _finalizer_loop(self):
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
if not self.active:
|
|
96
|
+
return
|
|
97
|
+
if self.awaiting_response or self.playing:
|
|
98
|
+
return
|
|
99
|
+
if self.last_chunk_ts and (time.time() - self.last_chunk_ts) * 1000 >= self.INACTIVITY_MS:
|
|
100
|
+
print("VA FINALIZE UTTERANCE (quiet)")
|
|
101
|
+
await self._finalize_utterance()
|
|
102
|
+
return
|
|
103
|
+
await asyncio.sleep(0.05)
|
|
104
|
+
except asyncio.CancelledError:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
async def _utterance_watchdog(self):
|
|
108
|
+
while True:
|
|
109
|
+
try:
|
|
110
|
+
await asyncio.sleep(0.1)
|
|
111
|
+
if not self.active or self.awaiting_response or self.playing:
|
|
112
|
+
continue
|
|
113
|
+
if self.capture_buf and self.last_chunk_ts and (time.time() - self.last_chunk_ts) * 1000 >= self.INACTIVITY_MS:
|
|
114
|
+
print("VA FINALIZE (watchdog)")
|
|
115
|
+
await self._finalize_utterance()
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
return
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
async def _finalize_utterance(self):
|
|
122
|
+
if not self.capture_buf:
|
|
123
|
+
return
|
|
124
|
+
pcm = bytes(self.capture_buf)
|
|
125
|
+
self.capture_buf.clear()
|
|
126
|
+
self.last_chunk_ts = 0
|
|
127
|
+
try:
|
|
128
|
+
dur = time.time() - (self._rx_start_ts or time.time())
|
|
129
|
+
print(f"VA RX END (device→hub) bytes={len(pcm)} dur={dur:.2f}s")
|
|
130
|
+
samples = len(pcm) // 2
|
|
131
|
+
exp = samples / 16000.0
|
|
132
|
+
if exp:
|
|
133
|
+
print(f"VA CAPTURE STATS: samples={samples} sec={exp:.2f} wall={dur:.2f} ratio={dur/exp:.2f}")
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
finally:
|
|
137
|
+
self._rx_started = False
|
|
138
|
+
if self._cloud_task and not self._cloud_task.done():
|
|
139
|
+
return
|
|
140
|
+
self._cloud_task = asyncio.create_task(self._cloud_roundtrip_and_play(pcm))
|
|
141
|
+
|
|
142
|
+
async def _cloud_roundtrip_and_play(self, pcm_bytes: bytes):
|
|
143
|
+
try:
|
|
144
|
+
await asyncio.wait_for(self._cloud_gate.wait(), timeout=30)
|
|
145
|
+
except asyncio.TimeoutError:
|
|
146
|
+
return
|
|
147
|
+
self.awaiting_response = True
|
|
148
|
+
try:
|
|
149
|
+
# Ensure we have an MCP token before contacting Website
|
|
150
|
+
if self.mcp_token is None:
|
|
151
|
+
try:
|
|
152
|
+
await self.ensure_mcp_token()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
# Hard guard: abort if token still missing
|
|
156
|
+
if self.mcp_token is None or not getattr(self.mcp_token, 'token', None):
|
|
157
|
+
raise RuntimeError("Missing MCP token for Website WS call")
|
|
158
|
+
|
|
159
|
+
if (not self._start_session_notified) and (not self._start_session_inflight):
|
|
160
|
+
try:
|
|
161
|
+
await self._start_cloud_session()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
else:
|
|
165
|
+
self._start_session_notified = True
|
|
166
|
+
mp3_bytes = await self._encode_mp3(pcm_bytes)
|
|
167
|
+
if not mp3_bytes:
|
|
168
|
+
return
|
|
169
|
+
print(f"VA TX START (hub→website) mp3={len(mp3_bytes)}B")
|
|
170
|
+
ws_url = "wss://simo.io/ws/voice-assistant/"
|
|
171
|
+
hub_uid = await sync_to_async(lambda: dynamic_settings['core__hub_uid'], thread_sensitive=True)()
|
|
172
|
+
hub_secret = await sync_to_async(lambda: dynamic_settings['core__hub_secret'], thread_sensitive=True)()
|
|
173
|
+
headers = {
|
|
174
|
+
"hub-uid": hub_uid,
|
|
175
|
+
"hub-secret": hub_secret,
|
|
176
|
+
"instance-uid": self.c.instance.uid,
|
|
177
|
+
"mcp-token": getattr(self.mcp_token, 'token', None),
|
|
178
|
+
"voice": self.voice,
|
|
179
|
+
"zone": self.zone
|
|
180
|
+
}
|
|
181
|
+
if not websockets:
|
|
182
|
+
raise RuntimeError("websockets library not available")
|
|
183
|
+
print(f"VA WS CONNECT {ws_url}")
|
|
184
|
+
|
|
185
|
+
kwargs = {'max_size': 10 * 1024 * 1024}
|
|
186
|
+
ws_params = inspect.signature(websockets.connect).parameters
|
|
187
|
+
if 'additional_headers' in ws_params:
|
|
188
|
+
kwargs['additional_headers'] = headers
|
|
189
|
+
else:
|
|
190
|
+
kwargs['extra_headers'] = headers
|
|
191
|
+
async with websockets.connect(ws_url, **kwargs) as ws:
|
|
192
|
+
print("VA WS OPEN")
|
|
193
|
+
await ws.send(mp3_bytes)
|
|
194
|
+
print("VA WS SENT (binary)")
|
|
195
|
+
deadline = time.time() + self.CLOUD_RESPONSE_TIMEOUT_SEC
|
|
196
|
+
mp3_reply = None
|
|
197
|
+
streaming = False
|
|
198
|
+
streaming_opus = False
|
|
199
|
+
sent_total = 0
|
|
200
|
+
stream_chunks = 0
|
|
201
|
+
stream_start_ts = None
|
|
202
|
+
opus_proc = None
|
|
203
|
+
pcm_forward_task = None
|
|
204
|
+
pcm_start_threshold = 8192 # ~256ms @ 16kHz s16le mono
|
|
205
|
+
pcm_buffer = bytearray()
|
|
206
|
+
ws_closed_ok = False
|
|
207
|
+
ws_closed_error = False
|
|
208
|
+
ws_closed_code = None
|
|
209
|
+
pcm_stats_sent = 0
|
|
210
|
+
async def _start_opus_decoder():
|
|
211
|
+
nonlocal opus_proc, pcm_forward_task, pcm_buffer, pcm_stats_sent
|
|
212
|
+
if opus_proc is not None:
|
|
213
|
+
return
|
|
214
|
+
try:
|
|
215
|
+
opus_proc = await asyncio.create_subprocess_exec(
|
|
216
|
+
'ffmpeg', '-v', 'error', '-i', 'pipe:0',
|
|
217
|
+
'-f', 's16le', '-ar', '16000', '-ac', '1', 'pipe:1',
|
|
218
|
+
stdin=asyncio.subprocess.PIPE,
|
|
219
|
+
stdout=asyncio.subprocess.PIPE,
|
|
220
|
+
stderr=asyncio.subprocess.PIPE,
|
|
221
|
+
)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
print('VA: failed to start ffmpeg for opus decode:', e, file=sys.stderr)
|
|
224
|
+
opus_proc = None
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
async def _pcm_forwarder():
|
|
228
|
+
nonlocal pcm_buffer, pcm_stats_sent, stream_start_ts, stream_chunks, sent_total
|
|
229
|
+
started = False
|
|
230
|
+
next_deadline = 0.0
|
|
231
|
+
try:
|
|
232
|
+
while True:
|
|
233
|
+
chunk = await opus_proc.stdout.read(4096)
|
|
234
|
+
if not chunk:
|
|
235
|
+
break
|
|
236
|
+
pcm_buffer.extend(chunk)
|
|
237
|
+
if not started and len(pcm_buffer) >= pcm_start_threshold:
|
|
238
|
+
started = True
|
|
239
|
+
if stream_start_ts is None:
|
|
240
|
+
stream_start_ts = time.time()
|
|
241
|
+
print('VA RX STREAM START (website→hub) opus->pcm')
|
|
242
|
+
next_deadline = time.time()
|
|
243
|
+
# forward in paced frames (~32ms for 1024B)
|
|
244
|
+
while started and len(pcm_buffer) >= self.PLAY_CHUNK_BYTES:
|
|
245
|
+
frame = bytes(pcm_buffer[:self.PLAY_CHUNK_BYTES])
|
|
246
|
+
del pcm_buffer[:self.PLAY_CHUNK_BYTES]
|
|
247
|
+
try:
|
|
248
|
+
# pacing
|
|
249
|
+
samples = len(frame) // 2
|
|
250
|
+
dt = samples / 16000.0
|
|
251
|
+
sleep_for = next_deadline - time.time()
|
|
252
|
+
if sleep_for > 0:
|
|
253
|
+
await asyncio.sleep(sleep_for)
|
|
254
|
+
await self.c.send(bytes_data=b"\x01" + frame)
|
|
255
|
+
self.last_tx_audio_ts = time.time()
|
|
256
|
+
sent_total += len(frame)
|
|
257
|
+
stream_chunks += 1
|
|
258
|
+
pcm_stats_sent += len(frame)
|
|
259
|
+
if not self.playing:
|
|
260
|
+
self.playing = True
|
|
261
|
+
next_deadline += dt
|
|
262
|
+
except Exception:
|
|
263
|
+
return
|
|
264
|
+
except asyncio.CancelledError:
|
|
265
|
+
return
|
|
266
|
+
except Exception as e:
|
|
267
|
+
print('VA: opus decode forward error:', e, file=sys.stderr)
|
|
268
|
+
finally:
|
|
269
|
+
# flush tail with pacing
|
|
270
|
+
if started and pcm_buffer:
|
|
271
|
+
try:
|
|
272
|
+
while pcm_buffer:
|
|
273
|
+
take = min(self.PLAY_CHUNK_BYTES, len(pcm_buffer))
|
|
274
|
+
frame = bytes(pcm_buffer[:take])
|
|
275
|
+
del pcm_buffer[:take]
|
|
276
|
+
samples = len(frame) // 2
|
|
277
|
+
dt = samples / 16000.0
|
|
278
|
+
sleep_for = next_deadline - time.time()
|
|
279
|
+
if sleep_for > 0:
|
|
280
|
+
await asyncio.sleep(sleep_for)
|
|
281
|
+
await self.c.send(bytes_data=b"\x01" + frame)
|
|
282
|
+
self.last_tx_audio_ts = time.time()
|
|
283
|
+
sent_total += len(frame)
|
|
284
|
+
stream_chunks += 1
|
|
285
|
+
next_deadline += dt
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
pcm_buffer = bytearray()
|
|
289
|
+
|
|
290
|
+
pcm_forward_task = asyncio.create_task(_pcm_forwarder())
|
|
291
|
+
while True:
|
|
292
|
+
remaining = deadline - time.time()
|
|
293
|
+
if remaining <= 0:
|
|
294
|
+
try:
|
|
295
|
+
await ws.close()
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
raise asyncio.TimeoutError("Cloud response timeout waiting for audio reply")
|
|
299
|
+
try:
|
|
300
|
+
msg = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
|
301
|
+
except asyncio.TimeoutError:
|
|
302
|
+
try:
|
|
303
|
+
await ws.close()
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
raise
|
|
307
|
+
except Exception as e:
|
|
308
|
+
# Connection closed or errored — inspect close code
|
|
309
|
+
try:
|
|
310
|
+
from websockets.exceptions import ConnectionClosed
|
|
311
|
+
except Exception:
|
|
312
|
+
ConnectionClosed = tuple()
|
|
313
|
+
if isinstance(e, ConnectionClosed):
|
|
314
|
+
try:
|
|
315
|
+
ws_closed_code = getattr(e, 'code', None)
|
|
316
|
+
except Exception:
|
|
317
|
+
ws_closed_code = None
|
|
318
|
+
if ws_closed_code == 1000 or self._end_after_playback:
|
|
319
|
+
ws_closed_ok = True
|
|
320
|
+
else:
|
|
321
|
+
ws_closed_error = True
|
|
322
|
+
break
|
|
323
|
+
# Any other exception (e.g., network reset) => treat as error end if streaming started
|
|
324
|
+
if streaming or streaming_opus:
|
|
325
|
+
ws_closed_error = True
|
|
326
|
+
break
|
|
327
|
+
# Otherwise, propagate to outer handler (will send error finish)
|
|
328
|
+
raise e
|
|
329
|
+
# Reset deadline on activity
|
|
330
|
+
deadline = time.time() + self.CLOUD_RESPONSE_TIMEOUT_SEC
|
|
331
|
+
if isinstance(msg, (bytes, bytearray)):
|
|
332
|
+
if streaming_opus:
|
|
333
|
+
# Feed opus bytes into decoder stdin
|
|
334
|
+
try:
|
|
335
|
+
if opus_proc is not None and opus_proc.stdin:
|
|
336
|
+
opus_proc.stdin.write(msg)
|
|
337
|
+
await opus_proc.stdin.drain()
|
|
338
|
+
except Exception as e:
|
|
339
|
+
print('VA: opus stdin write failed:', e, file=sys.stderr)
|
|
340
|
+
break
|
|
341
|
+
continue
|
|
342
|
+
if streaming:
|
|
343
|
+
if stream_start_ts is None:
|
|
344
|
+
stream_start_ts = time.time()
|
|
345
|
+
print("VA RX STREAM START (website→hub) pcm16le")
|
|
346
|
+
try:
|
|
347
|
+
await self.c.send(bytes_data=b"\x01" + bytes(msg))
|
|
348
|
+
self.last_tx_audio_ts = time.time()
|
|
349
|
+
sent_total += len(msg)
|
|
350
|
+
stream_chunks += 1
|
|
351
|
+
if not self.playing:
|
|
352
|
+
self.playing = True
|
|
353
|
+
except Exception:
|
|
354
|
+
break
|
|
355
|
+
continue
|
|
356
|
+
# Not in streaming mode: assume single MP3 blob
|
|
357
|
+
mp3_reply = bytes(msg)
|
|
358
|
+
print(f"VA RX START (website→hub) mp3={len(mp3_reply)}B")
|
|
359
|
+
break
|
|
360
|
+
else:
|
|
361
|
+
try:
|
|
362
|
+
data = json.loads(msg)
|
|
363
|
+
except Exception:
|
|
364
|
+
data = None
|
|
365
|
+
if isinstance(data, dict):
|
|
366
|
+
print(f"VA WS CTRL {data}")
|
|
367
|
+
# Streaming handshake
|
|
368
|
+
audio = data.get('audio') if isinstance(data.get('audio'), dict) else None
|
|
369
|
+
if audio and audio.get('format') == 'pcm16le':
|
|
370
|
+
if int(audio.get('sr', 0)) != 16000:
|
|
371
|
+
print("VA: unsupported stream rate, expecting 16k; ignoring stream")
|
|
372
|
+
else:
|
|
373
|
+
streaming = True
|
|
374
|
+
continue
|
|
375
|
+
if audio and audio.get('format') == 'opus':
|
|
376
|
+
# Start opus->pcm decoder
|
|
377
|
+
await _start_opus_decoder()
|
|
378
|
+
if opus_proc is not None:
|
|
379
|
+
streaming_opus = True
|
|
380
|
+
continue
|
|
381
|
+
if data.get('session') == 'finish':
|
|
382
|
+
self._end_after_playback = True
|
|
383
|
+
try:
|
|
384
|
+
await self.c.send_data(
|
|
385
|
+
{'command': 'va', 'session': 'finish',
|
|
386
|
+
'status': data.get('status', 'success')}
|
|
387
|
+
)
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
if 'reasoning' in data:
|
|
391
|
+
try:
|
|
392
|
+
await self.c.send_data({'command': 'va', 'reasoning': bool(data['reasoning'])})
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
if mp3_reply:
|
|
397
|
+
pcm_out = await self._decode_mp3(mp3_reply)
|
|
398
|
+
if pcm_out:
|
|
399
|
+
await self._play_to_device(pcm_out)
|
|
400
|
+
if self._end_after_playback:
|
|
401
|
+
await self._end_session(cloud_also=False)
|
|
402
|
+
self._end_after_playback = False
|
|
403
|
+
elif self._end_after_playback:
|
|
404
|
+
await self._end_session(cloud_also=False)
|
|
405
|
+
self._end_after_playback = False
|
|
406
|
+
elif streaming:
|
|
407
|
+
# Streaming ended; finalize playback stats
|
|
408
|
+
try:
|
|
409
|
+
elapsed = time.time() - (stream_start_ts or time.time())
|
|
410
|
+
audio_sec = (sent_total // 2) / 16000.0 if sent_total else 0.0
|
|
411
|
+
print(f"VA RX STREAM END (website→hub) sent≈{sent_total}B chunks={stream_chunks} elapsed={elapsed:.2f}s audio={audio_sec:.2f}s ratio={elapsed/audio_sec if audio_sec else 0:.2f}")
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
self.playing = False
|
|
415
|
+
if self._end_after_playback:
|
|
416
|
+
await self._end_session(cloud_also=False)
|
|
417
|
+
self._end_after_playback = False
|
|
418
|
+
elif streaming_opus:
|
|
419
|
+
# Close decoder stdin and let forwarder drain fully
|
|
420
|
+
try:
|
|
421
|
+
if opus_proc and opus_proc.stdin:
|
|
422
|
+
try:
|
|
423
|
+
opus_proc.stdin.close()
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
# Allow forwarder to finish without artificially short timeouts
|
|
429
|
+
if pcm_forward_task:
|
|
430
|
+
try:
|
|
431
|
+
await pcm_forward_task
|
|
432
|
+
except Exception:
|
|
433
|
+
try:
|
|
434
|
+
pcm_forward_task.cancel()
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
# Ensure process exits cleanly
|
|
438
|
+
try:
|
|
439
|
+
if opus_proc:
|
|
440
|
+
try:
|
|
441
|
+
await opus_proc.wait()
|
|
442
|
+
except Exception:
|
|
443
|
+
opus_proc.kill()
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
try:
|
|
447
|
+
elapsed = time.time() - (stream_start_ts or time.time())
|
|
448
|
+
audio_sec = (sent_total // 2) / 16000.0 if sent_total else 0.0
|
|
449
|
+
print(f"VA RX OPUS END sent≈{sent_total}B chunks={stream_chunks} elapsed={elapsed:.2f}s audio={audio_sec:.2f}s ratio={elapsed/audio_sec if audio_sec else 0:.2f}")
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
self.playing = False
|
|
453
|
+
if self._end_after_playback:
|
|
454
|
+
await self._end_session(cloud_also=False)
|
|
455
|
+
self._end_after_playback = False
|
|
456
|
+
elif self._end_after_playback:
|
|
457
|
+
await self._end_session(cloud_also=False)
|
|
458
|
+
self._end_after_playback = False
|
|
459
|
+
elif ws_closed_error:
|
|
460
|
+
# Website closed with a non-1000 code: finish with error immediately
|
|
461
|
+
try:
|
|
462
|
+
await self.c.send_data({'command': 'va', 'session': 'finish', 'status': 'error'})
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
await self._end_session(cloud_also=True)
|
|
466
|
+
elif ws_closed_ok:
|
|
467
|
+
# Normal close without explicit finish: keep session open for follow-up.
|
|
468
|
+
pass
|
|
469
|
+
except Exception as e:
|
|
470
|
+
print("VA WS ERROR:", e, file=sys.stderr)
|
|
471
|
+
print("VA: Cloud roundtrip failed\n", traceback.format_exc(), file=sys.stderr)
|
|
472
|
+
try:
|
|
473
|
+
await self.c.send_data({'command': 'va', 'session': 'finish', 'status': 'error'})
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
await self._end_session(cloud_also=True)
|
|
477
|
+
finally:
|
|
478
|
+
self.awaiting_response = False
|
|
479
|
+
if self.active and not self.playing and not self._end_after_playback:
|
|
480
|
+
await self._start_followup_timer()
|
|
481
|
+
|
|
482
|
+
async def _encode_mp3(self, pcm_bytes: bytes):
|
|
483
|
+
if lameenc is None:
|
|
484
|
+
loop = asyncio.get_running_loop()
|
|
485
|
+
return await loop.run_in_executor(None, lambda: self._encode_mp3_pydub(pcm_bytes))
|
|
486
|
+
def _enc():
|
|
487
|
+
enc = lameenc.Encoder()
|
|
488
|
+
enc.set_bit_rate(48)
|
|
489
|
+
enc.set_in_sample_rate(16000)
|
|
490
|
+
enc.set_channels(1)
|
|
491
|
+
enc.set_quality(2)
|
|
492
|
+
return enc.encode(pcm_bytes) + enc.flush()
|
|
493
|
+
try:
|
|
494
|
+
loop = asyncio.get_running_loop()
|
|
495
|
+
return await loop.run_in_executor(None, _enc)
|
|
496
|
+
except Exception:
|
|
497
|
+
print("VA: lameenc failed, fallback to pydub", file=sys.stderr)
|
|
498
|
+
loop = asyncio.get_running_loop()
|
|
499
|
+
return await loop.run_in_executor(None, lambda: self._encode_mp3_pydub(pcm_bytes))
|
|
500
|
+
|
|
501
|
+
def _encode_mp3_pydub(self, pcm_bytes: bytes):
|
|
502
|
+
if AudioSegment is None:
|
|
503
|
+
return None
|
|
504
|
+
audio = AudioSegment(data=pcm_bytes, sample_width=2, frame_rate=16000, channels=1)
|
|
505
|
+
out = io.BytesIO()
|
|
506
|
+
audio.export(out, format='mp3', bitrate='48k')
|
|
507
|
+
return out.getvalue()
|
|
508
|
+
|
|
509
|
+
async def _decode_mp3(self, mp3_bytes: bytes):
|
|
510
|
+
if AudioSegment is None:
|
|
511
|
+
return None
|
|
512
|
+
def _dec():
|
|
513
|
+
audio = AudioSegment.from_file(io.BytesIO(mp3_bytes), format='mp3')
|
|
514
|
+
audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2)
|
|
515
|
+
return audio.raw_data
|
|
516
|
+
try:
|
|
517
|
+
loop = asyncio.get_running_loop()
|
|
518
|
+
return await loop.run_in_executor(None, _dec)
|
|
519
|
+
except Exception:
|
|
520
|
+
print("VA: MP3 decode failed\n", traceback.format_exc(), file=sys.stderr)
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
async def _play_to_device(self, pcm_bytes: bytes):
|
|
524
|
+
self.playing = True
|
|
525
|
+
try:
|
|
526
|
+
print(f"VA TX START (hub→device) pcm={len(pcm_bytes)}B")
|
|
527
|
+
view = memoryview(pcm_bytes)
|
|
528
|
+
total = len(view)
|
|
529
|
+
pos = 0
|
|
530
|
+
sent_total = 0
|
|
531
|
+
next_deadline = time.time()
|
|
532
|
+
fudge = 0.0
|
|
533
|
+
pace_start = time.time()
|
|
534
|
+
chunks = 0
|
|
535
|
+
warmup = 1
|
|
536
|
+
while pos < total and self.c.connected:
|
|
537
|
+
chunk = view[pos:pos + self.PLAY_CHUNK_BYTES]
|
|
538
|
+
pos += len(chunk)
|
|
539
|
+
try:
|
|
540
|
+
await self.c.send(bytes_data=b"\x01" + bytes(chunk))
|
|
541
|
+
self.last_tx_audio_ts = time.time()
|
|
542
|
+
sent_total += len(chunk)
|
|
543
|
+
chunks += 1
|
|
544
|
+
except Exception:
|
|
545
|
+
break
|
|
546
|
+
if warmup > 0:
|
|
547
|
+
warmup -= 1
|
|
548
|
+
else:
|
|
549
|
+
samples = len(chunk) // 2
|
|
550
|
+
dt = samples / 16000.0
|
|
551
|
+
next_deadline += dt
|
|
552
|
+
drift = next_deadline - time.time()
|
|
553
|
+
sleep_for = drift + fudge
|
|
554
|
+
if sleep_for > 0:
|
|
555
|
+
await asyncio.sleep(sleep_for)
|
|
556
|
+
finally:
|
|
557
|
+
self.playing = False
|
|
558
|
+
try:
|
|
559
|
+
elapsed = time.time() - pace_start if 'pace_start' in locals() else 0.0
|
|
560
|
+
audio_sec = (sent_total // 2) / 16000.0 if sent_total else 0.0
|
|
561
|
+
print(f"VA TX END (hub→device) sent≈{sent_total}B chunks={chunks} elapsed={elapsed:.2f}s audio={audio_sec:.2f}s ratio={elapsed/audio_sec if audio_sec else 0:.2f}")
|
|
562
|
+
except Exception:
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
async def _start_followup_timer(self):
|
|
566
|
+
if self._followup_task and not self._followup_task.done():
|
|
567
|
+
self._followup_task.cancel()
|
|
568
|
+
async def _timer():
|
|
569
|
+
try:
|
|
570
|
+
await asyncio.sleep(self.FOLLOWUP_SEC)
|
|
571
|
+
if self.active and not self.playing and not self.awaiting_response and not self.capture_buf:
|
|
572
|
+
await self._end_session(cloud_also=False)
|
|
573
|
+
except asyncio.CancelledError:
|
|
574
|
+
return
|
|
575
|
+
self._followup_task = asyncio.create_task(_timer())
|
|
576
|
+
|
|
577
|
+
async def _idle_watchdog(self):
|
|
578
|
+
IDLE_SEC = 120
|
|
579
|
+
while True:
|
|
580
|
+
try:
|
|
581
|
+
await asyncio.sleep(2)
|
|
582
|
+
if not self.active:
|
|
583
|
+
continue
|
|
584
|
+
last_audio = max(self.last_rx_audio_ts or 0, self.last_tx_audio_ts or 0)
|
|
585
|
+
if not last_audio:
|
|
586
|
+
continue
|
|
587
|
+
if (time.time() - last_audio) > IDLE_SEC:
|
|
588
|
+
print("VA idle timeout reached (120s), ending session")
|
|
589
|
+
await self._end_session(cloud_also=True)
|
|
590
|
+
except asyncio.CancelledError:
|
|
591
|
+
return
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
async def _set_is_vo_active(self, flag: bool):
|
|
596
|
+
def _execute():
|
|
597
|
+
from simo.mcp_server.models import InstanceAccessToken
|
|
598
|
+
with transaction.atomic():
|
|
599
|
+
if flag:
|
|
600
|
+
self.mcp_token, _ = InstanceAccessToken.objects.get_or_create(
|
|
601
|
+
instance=self.c.colonel.instance, date_expired=None, issuer='sentinel'
|
|
602
|
+
)
|
|
603
|
+
else:
|
|
604
|
+
# Do NOT eagerly expire the token here; it may be in use
|
|
605
|
+
# by Website prewarm or by the chosen winner on this instance.
|
|
606
|
+
# Cleanup is handled by a scheduled task (1-day expiry).
|
|
607
|
+
self.mcp_token = None
|
|
608
|
+
self.c.colonel.is_vo_active = flag
|
|
609
|
+
self.c.colonel.save(update_fields=['is_vo_active'])
|
|
610
|
+
await sync_to_async(_execute, thread_sensitive=True)()
|
|
611
|
+
|
|
612
|
+
async def _finish_cloud_session(self):
|
|
613
|
+
try:
|
|
614
|
+
import requests
|
|
615
|
+
except Exception:
|
|
616
|
+
return
|
|
617
|
+
hub_uid = await sync_to_async(lambda: dynamic_settings['core__hub_uid'], thread_sensitive=True)()
|
|
618
|
+
hub_secret = await sync_to_async(lambda: dynamic_settings['core__hub_secret'], thread_sensitive=True)()
|
|
619
|
+
url = 'https://simo.io/ai/finish-session/'
|
|
620
|
+
payload = {
|
|
621
|
+
'hub_uid': hub_uid,
|
|
622
|
+
'hub_secret': hub_secret,
|
|
623
|
+
'instance_uid': self.c.instance.uid,
|
|
624
|
+
}
|
|
625
|
+
def _post():
|
|
626
|
+
try:
|
|
627
|
+
return requests.post(url, json=payload, timeout=5)
|
|
628
|
+
except Exception:
|
|
629
|
+
return None
|
|
630
|
+
for delay in (0, 2, 5):
|
|
631
|
+
if delay:
|
|
632
|
+
await asyncio.sleep(delay)
|
|
633
|
+
loop = asyncio.get_running_loop()
|
|
634
|
+
resp = await loop.run_in_executor(None, _post)
|
|
635
|
+
if resp is not None and getattr(resp, 'status_code', None) in (200, 204):
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
async def _start_cloud_session(self):
|
|
639
|
+
try:
|
|
640
|
+
import requests
|
|
641
|
+
except Exception:
|
|
642
|
+
return
|
|
643
|
+
hub_uid = await sync_to_async(lambda: dynamic_settings['core__hub_uid'], thread_sensitive=True)()
|
|
644
|
+
hub_secret = await sync_to_async(lambda: dynamic_settings['core__hub_secret'], thread_sensitive=True)()
|
|
645
|
+
url = 'https://simo.io/ai/start-session/'
|
|
646
|
+
payload = {
|
|
647
|
+
'hub_uid': hub_uid,
|
|
648
|
+
'hub_secret': hub_secret,
|
|
649
|
+
'instance_uid': self.c.instance.uid,
|
|
650
|
+
'mcp-token': getattr(self.mcp_token, 'token', None),
|
|
651
|
+
'zone': self.zone,
|
|
652
|
+
}
|
|
653
|
+
def _post():
|
|
654
|
+
try:
|
|
655
|
+
return requests.post(url, json=payload, timeout=5)
|
|
656
|
+
except Exception:
|
|
657
|
+
return None
|
|
658
|
+
for delay in (0, 2):
|
|
659
|
+
if delay:
|
|
660
|
+
await asyncio.sleep(delay)
|
|
661
|
+
loop = asyncio.get_running_loop()
|
|
662
|
+
resp = await loop.run_in_executor(None, _post)
|
|
663
|
+
if resp is not None and getattr(resp, 'status_code', None) in (200, 204):
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
async def _end_session(self, cloud_also: bool = False):
|
|
667
|
+
self.active = False
|
|
668
|
+
self.capture_buf.clear()
|
|
669
|
+
self.last_chunk_ts = 0
|
|
670
|
+
self.last_rx_audio_ts = 0
|
|
671
|
+
self.last_tx_audio_ts = 0
|
|
672
|
+
# Reset prewarm/session flags so next VA session can prewarm again
|
|
673
|
+
self._start_session_notified = False
|
|
674
|
+
self._start_session_inflight = False
|
|
675
|
+
self._prewarm_requested = False
|
|
676
|
+
# Close cloud gate so subsequent sessions don't bypass arbitration
|
|
677
|
+
try:
|
|
678
|
+
self._cloud_gate.clear()
|
|
679
|
+
except Exception:
|
|
680
|
+
pass
|
|
681
|
+
for t in (self._finalizer_task, self._cloud_task, self._play_task, self._followup_task):
|
|
682
|
+
if t and not t.done():
|
|
683
|
+
t.cancel()
|
|
684
|
+
self._finalizer_task = self._cloud_task = self._play_task = self._followup_task = None
|
|
685
|
+
await self._set_is_vo_active(False)
|
|
686
|
+
if cloud_also:
|
|
687
|
+
await self._finish_cloud_session()
|
|
688
|
+
|
|
689
|
+
async def shutdown(self):
|
|
690
|
+
await self._end_session(cloud_also=False)
|
|
691
|
+
|
|
692
|
+
async def open_as_winner(self):
|
|
693
|
+
if not self.active:
|
|
694
|
+
self.active = True
|
|
695
|
+
await self._set_is_vo_active(True)
|
|
696
|
+
try:
|
|
697
|
+
self._cloud_gate.set()
|
|
698
|
+
except Exception:
|
|
699
|
+
pass
|
|
700
|
+
# Best-effort notify Website immediately at session start
|
|
701
|
+
# Do it in background so we don't block audio pipeline
|
|
702
|
+
if not self._start_session_notified:
|
|
703
|
+
asyncio.create_task(self._start_cloud_session_safe())
|
|
704
|
+
|
|
705
|
+
async def _start_cloud_session_safe(self):
|
|
706
|
+
if self._start_session_inflight:
|
|
707
|
+
return
|
|
708
|
+
self._start_session_inflight = True
|
|
709
|
+
try:
|
|
710
|
+
await self._start_cloud_session()
|
|
711
|
+
except Exception:
|
|
712
|
+
pass
|
|
713
|
+
else:
|
|
714
|
+
self._start_session_notified = True
|
|
715
|
+
finally:
|
|
716
|
+
self._start_session_inflight = False
|
|
717
|
+
|
|
718
|
+
async def ensure_mcp_token(self):
|
|
719
|
+
"""Ensure self.mcp_token exists without toggling is_vo_active."""
|
|
720
|
+
def _execute():
|
|
721
|
+
from simo.mcp_server.models import InstanceAccessToken
|
|
722
|
+
token, _ = InstanceAccessToken.objects.get_or_create(
|
|
723
|
+
instance=self.c.colonel.instance, date_expired=None, issuer='sentinel'
|
|
724
|
+
)
|
|
725
|
+
return token
|
|
726
|
+
self.mcp_token = await sync_to_async(_execute, thread_sensitive=True)()
|
|
727
|
+
|
|
728
|
+
async def prewarm_on_first_audio(self):
|
|
729
|
+
"""Called on the first audio frames to notify Website ASAP, before winners."""
|
|
730
|
+
if self._start_session_notified or self._start_session_inflight or self._prewarm_requested:
|
|
731
|
+
return
|
|
732
|
+
self._prewarm_requested = True
|
|
733
|
+
try:
|
|
734
|
+
if self.mcp_token is None:
|
|
735
|
+
await self.ensure_mcp_token()
|
|
736
|
+
except Exception:
|
|
737
|
+
pass
|
|
738
|
+
# Fire and forget; internal flag will be set only on success
|
|
739
|
+
asyncio.create_task(self._start_cloud_session_safe())
|
|
740
|
+
|
|
741
|
+
async def reject_busy(self):
|
|
742
|
+
try:
|
|
743
|
+
await self.c.send_data({'command': 'va', 'session': 'finish', 'status': 'busy'})
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|
|
746
|
+
await self._end_session(cloud_also=False)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class VoiceAssistantArbitrator:
|
|
750
|
+
"""Encapsulates instance-wide arbitration and busy handling for a consumer."""
|
|
751
|
+
|
|
752
|
+
ARBITRATION_WINDOW_MS = 900
|
|
753
|
+
ARBITRATION_RANK_FIELD = 'avg2p5_s' # options: score|snr_db|avg2p5_s|peak2p5_s|energy_1s
|
|
754
|
+
WINNER_CONFIRM_GRACE_MS = 1500
|
|
755
|
+
|
|
756
|
+
def __init__(self, consumer, session: VoiceAssistantSession):
|
|
757
|
+
self.c = consumer
|
|
758
|
+
self.session = session
|
|
759
|
+
self._arb_started = False
|
|
760
|
+
self._arb_task = None
|
|
761
|
+
self._busy_rejected = False
|
|
762
|
+
self._last_active_scan = 0.0
|
|
763
|
+
|
|
764
|
+
async def maybe_reject_busy(self) -> bool:
|
|
765
|
+
now_ts = time.time()
|
|
766
|
+
if (not self._busy_rejected) and (now_ts - self._last_active_scan) > 0.3:
|
|
767
|
+
self._last_active_scan = now_ts
|
|
768
|
+
def _has_active_other():
|
|
769
|
+
return (self.c.colonel.__class__.objects
|
|
770
|
+
.filter(instance=self.c.instance, is_vo_active=True)
|
|
771
|
+
.exclude(id=self.c.colonel.id).exists())
|
|
772
|
+
try:
|
|
773
|
+
active_other = await sync_to_async(_has_active_other, thread_sensitive=True)()
|
|
774
|
+
except Exception:
|
|
775
|
+
active_other = False
|
|
776
|
+
if active_other:
|
|
777
|
+
self._busy_rejected = True
|
|
778
|
+
await self.session.reject_busy()
|
|
779
|
+
return True
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
def start_window_if_needed(self):
|
|
783
|
+
if not self._arb_started:
|
|
784
|
+
self._arb_started = True
|
|
785
|
+
if self._arb_task and not self._arb_task.done():
|
|
786
|
+
self._arb_task.cancel()
|
|
787
|
+
self._arb_task = asyncio.create_task(self._decide_after_window())
|
|
788
|
+
|
|
789
|
+
async def _decide_after_window(self):
|
|
790
|
+
try:
|
|
791
|
+
await asyncio.sleep(self.ARBITRATION_WINDOW_MS / 1000.0)
|
|
792
|
+
except asyncio.CancelledError:
|
|
793
|
+
return
|
|
794
|
+
await self._decide_arbitration()
|
|
795
|
+
|
|
796
|
+
async def _decide_arbitration(self):
|
|
797
|
+
try:
|
|
798
|
+
await sync_to_async(self.c.colonel.refresh_from_db, thread_sensitive=True)()
|
|
799
|
+
if getattr(self.c.colonel, 'is_vo_active', False):
|
|
800
|
+
await self.session.open_as_winner()
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
def _other_active():
|
|
804
|
+
return (self.c.colonel.__class__.objects
|
|
805
|
+
.filter(instance=self.c.instance, is_vo_active=True)
|
|
806
|
+
.exclude(id=self.c.colonel.id).exists())
|
|
807
|
+
if await sync_to_async(_other_active, thread_sensitive=True)():
|
|
808
|
+
if not self._busy_rejected:
|
|
809
|
+
self._busy_rejected = True
|
|
810
|
+
await self.session.reject_busy()
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
field = getattr(self, 'ARBITRATION_RANK_FIELD', 'avg2p5_s')
|
|
814
|
+
now = timezone.now()
|
|
815
|
+
window_start = now - timedelta(milliseconds=self.ARBITRATION_WINDOW_MS)
|
|
816
|
+
|
|
817
|
+
def _get_candidates():
|
|
818
|
+
qs = self.c.colonel.__class__.objects.filter(
|
|
819
|
+
instance=self.c.instance,
|
|
820
|
+
last_wake__gte=window_start,
|
|
821
|
+
)
|
|
822
|
+
lst = []
|
|
823
|
+
for col in qs:
|
|
824
|
+
stats = getattr(col, 'wake_stats', None) or {}
|
|
825
|
+
val = stats.get(field, -1)
|
|
826
|
+
lst.append((col.id, val))
|
|
827
|
+
return lst
|
|
828
|
+
|
|
829
|
+
cand = await sync_to_async(_get_candidates, thread_sensitive=True)()
|
|
830
|
+
if not cand:
|
|
831
|
+
await self.session.open_as_winner()
|
|
832
|
+
return
|
|
833
|
+
cand.sort(key=lambda t: (t[1], -t[0]))
|
|
834
|
+
chosen_id, _ = cand[-1]
|
|
835
|
+
|
|
836
|
+
if chosen_id == self.c.colonel.id:
|
|
837
|
+
@transaction.atomic
|
|
838
|
+
def _promote_self():
|
|
839
|
+
if self.c.colonel.__class__.objects.select_for_update().filter(
|
|
840
|
+
instance=self.c.instance, is_vo_active=True
|
|
841
|
+
).exists():
|
|
842
|
+
return False
|
|
843
|
+
cc = self.c.colonel.__class__.objects.select_for_update().get(id=self.c.colonel.id)
|
|
844
|
+
if not cc.is_vo_active:
|
|
845
|
+
cc.is_vo_active = True
|
|
846
|
+
cc.save(update_fields=['is_vo_active'])
|
|
847
|
+
return True
|
|
848
|
+
ok = await sync_to_async(_promote_self, thread_sensitive=True)()
|
|
849
|
+
if ok:
|
|
850
|
+
await self.session.open_as_winner()
|
|
851
|
+
else:
|
|
852
|
+
if not self._busy_rejected:
|
|
853
|
+
self._busy_rejected = True
|
|
854
|
+
await self.session.reject_busy()
|
|
855
|
+
return
|
|
856
|
+
|
|
857
|
+
deadline = time.time() + (self.WINNER_CONFIRM_GRACE_MS / 1000.0)
|
|
858
|
+
while time.time() < deadline:
|
|
859
|
+
def _chosen_active():
|
|
860
|
+
return self.c.colonel.__class__.objects.filter(
|
|
861
|
+
id=chosen_id, instance=self.c.instance, is_vo_active=True
|
|
862
|
+
).exists()
|
|
863
|
+
def _any_other_active():
|
|
864
|
+
return self.c.colonel.__class__.objects.filter(
|
|
865
|
+
instance=self.c.instance, is_vo_active=True
|
|
866
|
+
).exclude(id=self.c.colonel.id).exists()
|
|
867
|
+
chosen_active = await sync_to_async(_chosen_active, thread_sensitive=True)()
|
|
868
|
+
if chosen_active:
|
|
869
|
+
if not self._busy_rejected:
|
|
870
|
+
self._busy_rejected = True
|
|
871
|
+
await self.session.reject_busy()
|
|
872
|
+
return
|
|
873
|
+
if await sync_to_async(_any_other_active, thread_sensitive=True)():
|
|
874
|
+
if not self._busy_rejected:
|
|
875
|
+
self._busy_rejected = True
|
|
876
|
+
await self.session.reject_busy()
|
|
877
|
+
return
|
|
878
|
+
await asyncio.sleep(0.1)
|
|
879
|
+
|
|
880
|
+
@transaction.atomic
|
|
881
|
+
def _promote_self_fallback():
|
|
882
|
+
if self.c.colonel.__class__.objects.select_for_update().filter(
|
|
883
|
+
instance=self.c.instance, is_vo_active=True
|
|
884
|
+
).exists():
|
|
885
|
+
return False
|
|
886
|
+
cc = self.c.colonel.__class__.objects.select_for_update().get(id=self.c.colonel.id)
|
|
887
|
+
if not cc.is_vo_active:
|
|
888
|
+
cc.is_vo_active = True
|
|
889
|
+
cc.save(update_fields=['is_vo_active'])
|
|
890
|
+
return True
|
|
891
|
+
ok = await sync_to_async(_promote_self_fallback, thread_sensitive=True)()
|
|
892
|
+
if ok:
|
|
893
|
+
await self.session.open_as_winner()
|
|
894
|
+
else:
|
|
895
|
+
if not self._busy_rejected:
|
|
896
|
+
self._busy_rejected = True
|
|
897
|
+
await self.session.reject_busy()
|
|
898
|
+
except Exception:
|
|
899
|
+
print(traceback.format_exc(), file=sys.stderr)
|
|
900
|
+
try:
|
|
901
|
+
await self.session.open_as_winner()
|
|
902
|
+
except Exception:
|
|
903
|
+
pass
|