diff --git a/openpilot/starpilot/galaxy/developer_settings.py b/openpilot/starpilot/galaxy/developer_settings.py new file mode 100644 index 0000000000..8a127d51c0 --- /dev/null +++ b/openpilot/starpilot/galaxy/developer_settings.py @@ -0,0 +1,29 @@ +"""Galaxy-only, next-drive developer preferences.""" + +from openpilot.starpilot.longitudinal.voacc import KEY, DESCRIPTION +from openpilot.starpilot.saved_document import commit_exact +from openpilot.starpilot.saved_source import read_saved +from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState + + +class DeveloperSettingsOwner: + def __init__(self, params, authorized): + self.params = params + self.authorized = authorized + + def snapshot(self): + raw, readable = read_saved(self.params, KEY, 8) + valid = readable and raw in (None, b"0", b"1") + parked = self.authorized() + row = FeatureRow(KEY, "Use VOACC", ("On" if raw == b"1" else "Off") if valid else "Invalid saved value", + raw, ("Off", "On") if valid else (), available=parked and valid, + reason=(DESCRIPTION if parked else "Park the vehicle to change this setting.") if valid else + "Invalid saved value", default_value="Off") + return FeatureSettingsState("developer", "Developer driving options", "Changes apply next drive.", (row,), parked) + + def apply(self, request): + if request.key != KEY or request.value not in ("Off", "On") or request.expected not in (None, b"0", b"1"): + return False + result = commit_exact(self.params, key=KEY, max_bytes=8, raw=b"1" if request.value == "On" else b"0", + expected=request.expected, authorized=self.authorized, temp_prefix=".voacc-") + return result.committed and result.verified diff --git a/openpilot/starpilot/galaxy/tests/browser/developer-settings.cjs b/openpilot/starpilot/galaxy/tests/browser/developer-settings.cjs new file mode 100644 index 0000000000..c7208200ea --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/browser/developer-settings.cjs @@ -0,0 +1,71 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright') +const assert = require('node:assert/strict') + +;(async () => { + const browser = await chromium.launch({ headless: true, + ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }) + try { + const page = await browser.newPage() + const errors = [], saves = [] + let enabled = false, parked = true + page.on('pageerror', error => errors.push(error.message)) + await page.route('**/developer-settings-test', route => route.fulfill({ contentType: 'text/html', body: + '
' })) + await page.route('**/api/**', route => { + const path = new URL(route.request().url()).pathname + let data + if (path.endsWith('/connect/provider')) data = { active: 'comma', selected: 'comma', revision: 'a'.repeat(64), + restartRequired: false, canSelect: parked, providers: [ + { id: 'comma', label: 'comma', url: 'https://connect.comma.ai' }, + { id: 'konik', label: 'Konik', url: 'https://stable.konik.ai' }] } + else if (path.endsWith('/pages/developer')) data = { page: 'developer', title: 'Developer driving options', + view: 'developer', parked, rows: [{ label: 'Use VOACC', value: enabled ? 'On' : 'Off', choices: ['Off', 'On'], + available: parked, action: true, reason: 'Vision-only lead selection. Applies next drive.', defaultValue: 'Off' }] } + else if (path.endsWith('/pages/hub')) data = { page: 'hub', title: 'Driving Controls', view: 'hub', parked, + rows: [['slc', 'Speed Limit Controller'], ['curve', 'Curve Speed Controller'], ['conditional', 'Conditional Driving Modes'], + ['profiles', 'Long Planner'], ['aggressive', 'Aggressive Personality']].map(([page, label]) => ({ page, label, available: true })) } + else if (path.endsWith('/preview')) { + saves.push(route.request().postDataJSON()) + data = { intent: 'voacc', proposed: 'On', question: 'Set Use VOACC to On?' } + } else if (path.endsWith('/confirm')) { enabled = true; data = { saved: true } } + else return route.fulfill({ status: 404, json: { error: 'Unexpected endpoint' } }) + return route.fulfill({ json: data }) + }) + await page.goto((process.env.GALAXY_URL || 'http://127.0.0.1:8765/') + 'developer-settings-test') + await page.evaluate(async () => { + const { createApp } = await import('./vendor/vue/vue.esm-browser.js') + const { SettingsPage } = await import('./js/settings.js') + window.mount = section => { + window.app?.unmount() + window.app = createApp(SettingsPage, { mode: 'local', unauthorized() {}, initialSection: section }) + window.vm = window.app.mount('#test') + } + mount('developer') + }) + const toggle = page.getByRole('switch', { name: 'Use VOACC', exact: true }) + await toggle.waitFor() + assert.equal(await toggle.isChecked(), false) + await toggle.click() + await page.waitForFunction(() => vm.state.status === 'ready' && vm.state.data.rows[0].value === 'On') + assert.equal(await toggle.isChecked(), true) + assert.equal(saves[0].value, 'On') + for (const width of [320, 1280]) { + await page.setViewportSize({ width, height: 900 }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) + } + await page.getByRole('button', { name: 'Longitudinal (Speed & Following)', exact: true }).click() + await page.getByText('Long Planner', { exact: true }).waitFor() + await page.waitForFunction(() => document.querySelectorAll('.gx-panel-stage .gx-row').length === 5) + const order = await page.locator('.gx-panel-stage .gx-row').allTextContents() + assert.equal(order.length, 5) + for (const [index, label] of ['Long Planner', 'Conditional Driving Modes', 'Curve Speed Controller', + 'Speed Limit Controller', 'Aggressive Personality'].entries()) assert.ok(order[index].includes(label)) + assert.equal(await toggle.count(), 0) + parked = false + await page.getByRole('button', { name: 'Developer', exact: true }).click() + await toggle.waitFor() + assert.equal(await toggle.isDisabled(), true) + assert.deepEqual(errors, []) + console.log('Developer VOACC: saved toggle, parked guard, phone/desktop layout and longitudinal ordering passed.') + } finally { await browser.close() } +})().catch(error => { console.error(error); process.exit(1) }) diff --git a/openpilot/starpilot/galaxy/tests/browser/layout-scenes.cjs b/openpilot/starpilot/galaxy/tests/browser/layout-scenes.cjs new file mode 100644 index 0000000000..54e61d0297 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/browser/layout-scenes.cjs @@ -0,0 +1,94 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright') +const { execFileSync } = require('node:child_process') +const { readFileSync, mkdirSync } = require('node:fs') +const path = require('node:path') +const assert = require('node:assert/strict') + +const web = path.resolve(__dirname, '../../web') +const fixtures = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c', ` +import json +from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata +from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, layout_metadata_for_viewport +doc=default_document() +for layout in doc['layouts'].values(): + for widget in layout.values(): widget['enabled']=True +native=dict(document=doc,defaults=doc,metadata=customization_metadata(),revision='scenes',editable=True,valid=True,activeProfile='large',supportedProfiles=['large','compact']) +layout=default_layout_for_viewport((2880,1080)) +for widget in layout['widgets'].values(): widget['enabled']=True +projection=dict(version=1,document=layout,defaults=layout,metadata=layout_metadata_for_viewport((2880,1080)),screen=dict(width=1280,height=720,margin_width=0,margin_height=240),revision='aa-scenes',editable=True,valid=True,colors=dict(palette=doc['palette'],widgetColors=doc['widgetColors']['large'],roadColors={})) +print(json.dumps(dict(native=native,projection=projection))) +`], { encoding: 'utf8' })) + +;(async () => { + const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}) }) + const output = process.env.SCENE_SCREENSHOTS + const nativeArt = process.env.NATIVE_SCENE_ART + if (output) mkdirSync(output, { recursive: true }) + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }) + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.route('http://scenes.test/**', async route => { + const pathname = new URL(route.request().url()).pathname + if (pathname === '/api/ui/layout/preview') { + if (!nativeArt) return route.fulfill({ status: 503, json: { error: 'Scene sketch test' } }) + const { profile, scene, art } = route.request().postDataJSON() + return route.fulfill({ contentType: 'image/png', body: readFileSync(path.join(nativeArt, `${profile}-${scene}-${art === true ? 'art' : 'backdrop'}.png`)) }) + } + if (pathname.startsWith('/api/')) return route.fulfill({ json: pathname === '/api/android-auto/layout' ? fixtures.projection : fixtures.native }) + if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: ` + +