Add offroad alert volume previews to Galaxy

This commit is contained in:
firestar5683
2026-11-06 11:26:19 -06:00
parent 4f5cf21e9e
commit 359cb9fda6
9 changed files with 290 additions and 14 deletions
+114
View File
@@ -0,0 +1,114 @@
"""Bounded, offroad-only previews through the normal sound owner."""
import fcntl
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
import threading
import time
from openpilot.starpilot.audio.alert_volume import SPECS
ALERTS = {
'WarningImmediateVolume': 'warningImmediate', 'WarningSoftVolume': 'warningSoft',
'RefuseVolume': 'refuse', 'PromptDistractedVolume': 'promptDistracted',
'EngageVolume': 'engage', 'DisengageVolume': 'disengage', 'PromptVolume': 'prompt',
}
class AlertPreviewError(Exception):
def __init__(self, message, status=409):
super().__init__(message)
self.status = status
class AlertPreview:
def __init__(self, parked, runner=subprocess.run):
self.parked, self.runner = parked, runner
self.lock = threading.Lock()
def play(self, payload):
if type(payload) is not dict or set(payload) != {'key'} or type(payload['key']) is not str or payload['key'] not in SPECS:
raise AlertPreviewError('Choose an alert volume to preview.', 400)
if not self.parked():
raise AlertPreviewError('Put the comma offroad before testing an alert.')
if not self.lock.acquire(blocking=False):
raise AlertPreviewError('An alert preview is already playing.')
try:
result = self.runner([sys.executable, '-m', 'openpilot.starpilot.audio.preview', payload['key']],
cwd=Path(__file__).resolve().parents[3], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=8)
if result.returncode == 2:
raise AlertPreviewError('Preview stopped. Keep the comma offroad and close Driver Camera.')
if result.returncode:
raise AlertPreviewError('The comma could not play this alert. Try again.', 503)
return {'played': True}
except subprocess.TimeoutExpired as error:
raise AlertPreviewError('The preview timed out. Try again.', 503) from error
finally:
self.lock.release()
def play_saved_alert(key, *, params, context, sound, audio, alert, now=time.monotonic, sleep=time.sleep):
if key not in ALERTS or not context.offroad() or params.get_bool('IsDriverViewEnabled'):
raise AlertPreviewError('Offroad audio is unavailable.')
context.evidence_wait_ms = 0
sound.refresh_saved_volumes(now())
sound.update_alert(alert)
samples = sound.loaded_sounds[alert]
duration = 2.0 if ALERTS[key] in ('warningSoft', 'warningImmediate', 'promptDistracted') else min(3.0, len(samples) / 48000)
if not context.offroad():
raise AlertPreviewError('Offroad audio is unavailable.')
stream = sound.get_stream(audio)
if not context.offroad() or params.get_bool('IsDriverViewEnabled'):
stream.close()
raise AlertPreviewError('Preview stopped before playback.')
with stream:
deadline = now() + duration
while now() < deadline:
if not context.offroad() or params.get_bool('IsDriverViewEnabled'):
raise AlertPreviewError('Preview stopped.')
sound.update_critical_sound(now())
if sound.native_audio is not None:
sound.native_audio.publish()
sleep(.05)
def main():
if len(sys.argv) != 2 or sys.argv[1] not in ALERTS:
return 2
from openpilot.common.params import Params
from openpilot.starpilot.galaxy.settings import LiveContextSource
params = Params()
context = LiveContextSource(params)
path = Path(tempfile.gettempdir()) / f'starpilot-alert-preview-{os.getuid()}.lock'
descriptor = None
try:
if not context.offroad() or params.get_bool('IsDriverViewEnabled'):
return 2
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
return 2
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
from openpilot.selfdrive.ui.soundd import Soundd, AudibleAlert
from openpilot.system import micd
import sounddevice
micd.patch_sounddevice(sounddevice)
key = sys.argv[1]
play_saved_alert(key, params=params, context=context, sound=Soundd(params), audio=sounddevice,
alert=getattr(AudibleAlert, ALERTS[key]))
return 0
except (AlertPreviewError, BlockingIOError):
return 2
finally:
context.close()
if descriptor is not None:
os.close(descriptor)
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,98 @@
from contextlib import contextmanager
from types import SimpleNamespace
import subprocess
import unittest
from unittest.mock import Mock
from openpilot.starpilot.audio.alert_volume import SPECS
from openpilot.starpilot.audio.preview import ALERTS, AlertPreview, AlertPreviewError, play_saved_alert
class PreviewTests(unittest.TestCase):
def test_all_volume_rows_have_a_real_alert(self):
self.assertEqual(set(ALERTS), set(SPECS))
def test_saved_key_only_and_offroad_authorization(self):
runner = Mock(return_value=SimpleNamespace(returncode=0))
preview = AlertPreview(lambda: False, runner)
for body in ({}, {'key': []}, {'key': 'bad'}, {'key': 'EngageVolume', 'volume': 100}):
with self.assertRaises(AlertPreviewError):
preview.play(body)
with self.assertRaises(AlertPreviewError):
preview.play({'key': 'EngageVolume'})
runner.assert_not_called()
def test_preview_uses_fixed_argv_and_reaps_with_bounded_run(self):
runner = Mock(return_value=SimpleNamespace(returncode=0))
preview = AlertPreview(lambda: True, runner)
for key in SPECS:
self.assertEqual(preview.play({'key': key}), {'played': True})
args, kwargs = runner.call_args
self.assertEqual(args[0][-2:], ['openpilot.starpilot.audio.preview', key])
self.assertEqual(kwargs['timeout'], 8)
self.assertIs(kwargs['stdin'], subprocess.DEVNULL)
self.assertTrue(preview.lock.acquire(blocking=False))
with self.assertRaises(AlertPreviewError):
preview.play({'key': 'EngageVolume'})
preview.lock.release()
def test_errors_release_preview_ownership(self):
for outcome in (SimpleNamespace(returncode=2), SimpleNamespace(returncode=1), subprocess.TimeoutExpired('preview', 8)):
runner = Mock(side_effect=outcome) if isinstance(outcome, Exception) else Mock(return_value=outcome)
preview = AlertPreview(lambda: True, runner)
with self.assertRaises(AlertPreviewError):
preview.play({'key': 'EngageVolume'})
self.assertTrue(preview.lock.acquire(blocking=False))
preview.lock.release()
def test_worker_uses_normal_sound_stream_and_stops_when_offroad_evidence_is_lost(self):
for revoked in (False, True):
self._worker_stream_case(revoked)
def _worker_stream_case(self, revoked):
clock = [0.0]
stream = []
context = SimpleNamespace(offroad=lambda: not revoked or clock[0] < .1, evidence_wait_ms=750)
params = SimpleNamespace(get_bool=lambda _: False)
@contextmanager
def audio_stream(_):
stream.append('opened')
try:
yield
finally:
stream.append('closed')
sound = SimpleNamespace(refresh_saved_volumes=Mock(), update_alert=Mock(),
loaded_sounds={7: [0] * 48000}, get_stream=audio_stream, update_critical_sound=Mock(), native_audio=None)
def sleep(seconds):
clock[0] += seconds
def call():
return play_saved_alert('EngageVolume', params=params, context=context, sound=sound, audio=object(),
alert=7, now=lambda: clock[0], sleep=sleep)
if revoked:
with self.assertRaises(AlertPreviewError):
call()
else:
call()
self.assertEqual(stream, ['opened', 'closed'])
sound.update_alert.assert_called_once_with(7)
sound.refresh_saved_volumes.assert_called_once()
self.assertLessEqual(clock[0], 1.05)
def test_driver_camera_prevents_any_audio_stream(self):
sound = SimpleNamespace(get_stream=Mock())
with self.assertRaises(AlertPreviewError):
play_saved_alert('EngageVolume', params=SimpleNamespace(get_bool=lambda _: True),
context=SimpleNamespace(offroad=lambda: True), sound=sound, audio=None, alert=1)
sound.get_stream.assert_not_called()
def test_state_is_rechecked_after_initializing_the_audio_stream(self):
permissions = iter((True, True, False))
stream = Mock()
sound = SimpleNamespace(refresh_saved_volumes=Mock(), update_alert=Mock(),
loaded_sounds={7: [0] * 48000}, get_stream=Mock(return_value=stream))
with self.assertRaises(AlertPreviewError):
play_saved_alert('EngageVolume', params=SimpleNamespace(get_bool=lambda _: False),
context=SimpleNamespace(offroad=lambda: next(permissions)), sound=sound, audio=None, alert=7)
stream.close.assert_called_once()
+8 -5
View File
@@ -17,6 +17,7 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.galaxy.batch_routes import change_drive_state, delete_records, non_default_settings, preview_layout, preview_settings_recovery
from openpilot.starpilot.audio.preview import AlertPreview, AlertPreviewError
from openpilot.starpilot.galaxy.access import AccessStatus, default_owner
from openpilot.starpilot.galaxy.auth import LocalSessions
from openpilot.starpilot.galaxy.http_server import (_LocalHTTPHandler, _LocalHTTPServer, allowed_authority,
@@ -99,7 +100,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
starport_client=None, navigation=None, offline_maps=None, drive_state=None, cloud_provider=None, cloud_offroad=None, projection_layout=None,
local_access=None, tmux_live=None, starport_logs=None, starport_log_sender=None, hotspot=None, toyota_tools=None,
replay_client=None, replay_root=None,
spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None, local_hostnames=None):
spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None, local_hostnames=None, sound_preview=None):
from openpilot.starpilot.galaxy.local_hostnames import LocalHostnames
hostname_source = local_hostnames if local_hostnames is not None else LocalHostnames(clock=clock)
from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH
@@ -252,6 +253,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
favorites_source = favorites
favorites_lock = threading.Lock()
sound_source = sounds
alert_preview = sound_preview or AlertPreview(parked=parked)
sounds_lock = threading.Lock()
def drive_owner():
@@ -1413,7 +1415,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
return
try:
result = sound_owner().snapshot()
except SoundDownloadError as error:
except (SoundDownloadError, AlertPreviewError) as error:
self.json(error.status, {'error': str(error)})
except (OSError, ValueError, RuntimeError):
self.json(503, {'error': 'Sound packs are unavailable'})
@@ -1680,7 +1682,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
'/api/models/active', '/api/models/preferences', '/api/models/download', '/api/models/download_all',
'/api/models/cancel', '/api/models/delete', '/api/models/refresh_manifest', '/api/models/jetlink',
'/api/models/laboratory', '/api/models/laboratory/download', '/api/models/laboratory/delete',
'/api/sounds/download', '/api/sounds/cancel', '/api/software/action', '/api/drives/ignore',
'/api/sounds/preview', '/api/sounds/download', '/api/sounds/cancel', '/api/software/action', '/api/drives/ignore',
'/api/recordings/delete-videos', '/api/sentry/notifications', '/api/sentry/events/delete', '/api/crash-reports/delete',
'/api/navigation/search', '/api/navigation/action', '/api/navigation/offline', '/api/drive-state/action',
'/api/toyota-tools/action', '/api/vehicle-selection/preview', '/api/vehicle-selection/confirm'):
@@ -2269,8 +2271,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
if self.settings_session() != identity:
self.json(401, {'error': 'Sign in to Galaxy'})
return
result = sound_owner().action(path.rsplit('/', 1)[-1], payload)
except SoundDownloadError as error:
result = (alert_preview.play(payload) if path == '/api/sounds/preview' else
sound_owner().action(path.rsplit('/', 1)[-1], payload))
except (SoundDownloadError, AlertPreviewError) as error:
self.json(error.status, {'error': str(error)})
except (OSError, ValueError, RuntimeError):
self.json(503, {'error': 'Sound packs are unavailable'})
+2 -1
View File
@@ -394,7 +394,8 @@ def _projection(row: FeatureRow) -> dict:
"repairValue": row.repair_value, "defaultValue": row.default_value, "resetAvailable": row_default(row) is not None,
"displayScale": "lane-strength" if row.key == "LaneCenteringStrength" else None,
**({"coordinate": row.key.removeprefix("torque:surface:")} if row.key.startswith("torque:surface:") and row.key != "torque:surface:reset" else {}),
"heading": is_heading(row.key)}
"heading": is_heading(row.key),
**({"previewSound": row.key} if row.key in SOUND_SPECS else {})}
def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
@@ -239,3 +239,26 @@ for (const row of [{ value: "30", minimum: 1, maximum: 30, step: 1, unit: "h" },
assert.match(template, /presentedRow\.reason && !row\.heading/)
}
console.log("Settings controls: section headings render as labels only")
const previewVolume = control({ ...numeric, previewSound: "EngageVolume" })
let finishPreview
const playedKeys = []
previewVolume.vm.testAlert = key => { playedKeys.push(key); return new Promise(resolve => { finishPreview = resolve }) }
const playing = previewVolume.vm.previewSound()
assert.equal(previewVolume.vm.testingSound, true)
assert.deepEqual(playedKeys, ["EngageVolume"])
await previewVolume.vm.previewSound()
assert.equal(playedKeys.length, 1)
finishPreview()
await playing
assert.equal(previewVolume.vm.testingSound, false)
assert.deepEqual(previewVolume.commits, [])
previewVolume.vm.testAlert = async () => { throw new Error("Keep the comma offroad") }
await previewVolume.vm.previewSound()
assert.equal(previewVolume.vm.soundError, "Keep the comma offroad")
assert.equal(previewVolume.vm.testingSound, false)
previewVolume.vm.disabled = true
previewVolume.vm.testAlert = () => { throw new Error("disabled preview must not run") }
await previewVolume.vm.previewSound()
assert.equal(previewVolume.vm.soundError, "Keep the comma offroad")
@@ -1892,3 +1892,12 @@ progress { max-width: 100%; accent-color: var(--primary); }
.gx-guided__link:hover:not(:disabled) { text-decoration: underline; }
.gx-guided__link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.gx-guided__link:disabled { opacity: .5; cursor: default; }
.gx-slider-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-left: auto; }
.gx-alert-preview { min-width: 118px; min-height: 44px; gap: 8px; white-space: nowrap; }
.gx-alert-preview[aria-busy="true"] .bi { animation: gx-preview-pulse .7s ease-in-out infinite alternate; }
.gx-alert-preview-note { margin: 0; font-size: var(--fs-xs); color: var(--on-surface-variant); line-height: 1.4; }
@keyframes gx-preview-pulse { to { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .gx-alert-preview[aria-busy="true"] .bi { animation: none; } }
.gx-slider-header--preview { align-items: center; flex-wrap: wrap; }
.gx-slider-header .gx-slider-actions .gx-setting-default { margin: 0; }
@@ -67,9 +67,10 @@ export const GalaxySettingRow = {
busy: { type: Boolean, default: false },
feedback: { type: String, default: "saved" },
saveValue: { type: Function, required: true },
testAlert: { type: Function, default: null },
},
emits: ["open", "review", "reset-default"],
data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false } },
data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false, testingSound: false, soundError: "" } },
computed: {
presentedRow() { return presentedSetting(this.row) },
control() { return settingControl(this.row) },
@@ -94,6 +95,14 @@ export const GalaxySettingRow = {
},
},
methods: {
async previewSound() {
if (this.locked || this.testingSound || !this.testAlert || !this.row.previewSound) return
this.testingSound = true
this.soundError = ""
try { await this.testAlert(this.row.previewSound) }
catch (error) { this.soundError = error?.message || "The alert could not be played." }
finally { this.testingSound = false }
},
async commit(value) {
if (this.locked) return
if (String(value) === String(this.presentedRow.value)) { this.preview = undefined; return }
@@ -231,17 +240,23 @@ export const GalaxySettingRow = {
<span class="gx-switch__track"></span><span class="gx-switch__thumb"></span>
</label>
<div v-else-if="control === 'slider'" class="gx-slider-row" :class="{ 'is-fine-scrubbing': isFineScrubbing }">
<div class="gx-slider-header"><span class="gx-row__value">{{ displayValue }}</span>
<div class="gx-slider-header" :class="{ 'gx-slider-header--preview': testAlert && row.previewSound }"><span class="gx-row__value">{{ displayValue }}</span>
<span v-if="interacting" class="gx-slider-hint">{{ isFineScrubbing ? 'Fine scrubbing' : 'Hold to fine scrub' }}</span>
<span class="gx-slider-actions">
<button v-if="testAlert && row.previewSound" type="button" class="gx-btn gx-btn--tonal gx-alert-preview"
:disabled="locked || testingSound" :aria-label="'Test ' + row.label + ' on your comma'" :aria-busy="testingSound"
@click="previewSound"><i class="bi" :class="testingSound ? 'bi-soundwave' : 'bi-volume-up'" aria-hidden="true"></i>
{{ testingSound ? 'Playing…' : 'Test alert' }}</button>
<button v-if="showDefault" type="button"
class="gx-btn gx-btn--tonal gx-setting-default" :disabled="dimmed || !row.resetAvailable"
:aria-label="'Reset ' + row.label + ' to default'" :title="'Default: ' + presentedRow.defaultValue"
@click="$emit('reset-default', index)">Default</button></div>
@click="$emit('reset-default', index)">Default</button></span></div>
<input ref="slider" type="range" class="gx-slider" :aria-label="row.label" :aria-valuetext="displayValue"
:min="bounds.min" :max="bounds.max" :step="bounds.step" :value="sliderValue" :disabled="dimmed"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@pointerdown="onSliderPointerDown" @pointermove="onSliderPointerMove" @pointerup="onSliderPointerEnd"
@pointercancel="onSliderCancel" @lostpointercapture="onSliderCancel" @keydown="beginInteract">
<p v-if="soundError || testingSound" class="gx-alert-preview-note" role="status" aria-live="polite">{{ soundError || 'Playing on your comma…' }}</p>
<div class="gx-slider-meta"><span>{{ bounds.min }} to {{ bounds.numericMax ?? bounds.max }} {{ presentedRow.unit }}<template v-if="bounds.auto"> · Auto at right</template></span><span>Step: {{ bounds.step }} {{ presentedRow.unit }}</span></div>
</div>
<GalaxySelect v-else-if="control === 'select'" class="gx-field" :aria-label="row.label" :value="currentValue" :disabled="dimmed" @change="onSelect">
+17 -4
View File
@@ -1,3 +1,4 @@
import { requestJson } from "./startup.js"
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { connectionError } from "./polling.js"
@@ -269,13 +270,13 @@ export const SettingsPage = {
const navigation = route.view
const navigationKey = `${props.initialPage}:${props.initialSection || ""}`
const saved = navigation.settings?.key === navigationKey ? navigation.settings : null
const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 })
const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, testingSound: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 })
if (saved) Object.assign(state, { section: saved.section, query: saved.query, searchOpen: saved.searchOpen, parents: saved.parents.slice(), developerOpen: saved.developerOpen })
const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed, sections: SETTINGS_SECTIONS, navigation, navigationKey, restoredPage: saved?.page }
},
computed: {
busy() { return settingsBusy(this.state) },
busy() { return settingsBusy(this.state) || !!this.state.testingSound },
activeSection() {
if (this.state.developerOpen) return this.sections.at(-1)
const page = (this.state.data?.page || "hub").split("/")[0]
@@ -335,6 +336,18 @@ export const SettingsPage = {
this.feed.stop()
},
methods: {
async testAlert(key) {
if (this.busy) return
this.state.testingSound = true
try {
await requestJson("./api/sounds/preview", { timeout: 10000, request: {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }),
} })
} catch (error) {
if (error?.status === 401) this.unauthorized()
throw error
} finally { this.state.testingSound = false }
},
open(page) {
if (this.busy) return
this.$emit?.("nested")
@@ -426,7 +439,7 @@ export const SettingsPage = {
</div>
<p class="gx-page-description" v-if="state.developerOpen">Choose developer driving options and cloud services.</p>
<p class="gx-page-description" v-else-if="initialPage === 'pip'">Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.</p>
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'sounds'">Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.</p>
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'sounds'">Adjust alert and chime volumes. Test alert plays on your comma while offroad; Auto previews a quiet-cabin starting level. Immediate warnings keep their safety volume ramp.</p>
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'display'">Adjust brightness and screen timing when custom display settings are on.</p>
<p class="gx-page-description" v-else-if="initialPage === 'lane_change'">Configure lane-change assistance, signal timing, steering pace and following gap.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'conditional' || state.data?.page.startsWith('conditional/')">Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.</p>
@@ -464,7 +477,7 @@ export const SettingsPage = {
</div>
<div v-if="state.searchOpen && !cardIndex" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
<GalaxySettingRow v-for="{ row, index } in card.entries" :key="rowKey(row, index)" :row="row" :index="index"
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)"
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)" :test-alert="state.data.page === 'sounds' ? testAlert : null"
@open="open" @review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
<div v-if="!cardIndex && !visibleRows.length" class="gx-empty">No matching settings.</div>
</section>
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "4c66eab4bc2ebf4cfba3329df36fac04a20c572295a7da22b03616013b3bd5d9", "schema_version": 1}
{"protected_inventory_sha256": "0a96cfa499ada974607b1255975f78be11f09ce5f3d1904e2a27598ca08e3d07", "schema_version": 1}