Restore Small UI sign and parked Galaxy offroad

This commit is contained in:
firestar5683
2026-10-22 10:52:58 -05:00
parent 9ab83d8d59
commit d69a9f4db5
10 changed files with 61 additions and 40 deletions
+8 -1
View File
@@ -31,13 +31,20 @@ class DriveStateControl:
'overrideAllowed': self.cached[2], 'reason': self.cached[3]}
def change(self, mode, revision, authorized):
# Deliberate local requests may stop driving services while moving; Galaxy rejects Offroad in its action handler.
# Deliberate local requests may stop driving services while moving; Galaxy uses a separate Park-checked request.
result = self.owner.request(mode, expected_revision=revision, authorized=authorized,
override_allowed=lambda: True)
with self.lock:
self.cached = None
return result
def change_galaxy(self, mode, revision, authorized):
result = self.owner.request(mode, expected_revision=revision, authorized=authorized,
override_allowed=lambda: self.physical.status(require_park=True)['allowed'])
with self.lock:
self.cached = None
return result
@staticmethod
def next_mode(status):
mode = Mode(status['mode'])
+2 -2
View File
@@ -22,7 +22,7 @@ class PhysicalSource:
def allowed(self) -> bool:
return self.status()['allowed']
def status(self, *, updating_offroad=False):
def status(self, *, updating_offroad=False, require_park=False):
def result(allowed, reason=None):
return {'allowed': bool(allowed), 'reason': reason}
with self.lock:
@@ -66,7 +66,7 @@ class PhysicalSource:
if len(pandas) == 0 or any(str(p.pandaType) == 'unknown' for p in pandas):
return result(False, 'No recognized Panda is reporting vehicle status')
ignition = any(p.ignitionLine or p.ignitionCan for p in pandas)
if not ignition or updating_offroad and all(str(p.safetyModel) == 'noOutput' and not p.controlsAllowed for p in pandas):
if not require_park and (not ignition or updating_offroad and all(str(p.safetyModel) == 'noOutput' and not p.controlsAllowed for p in pandas)):
safe = all(str(p.safetyModel) == 'noOutput' and not p.controlsAllowed for p in pandas)
return result(safe, None if safe else 'Panda must confirm noOutput safety and controls disabled')
if not fresh('carState', True):
@@ -181,3 +181,14 @@ def test_forced_offroad_update_rechecks_freshness(fixture, service):
assert f.source.update_ready(True)
f.sm.logMonoTime[service] = 1
assert not f.source.update_ready(True)
@pytest.mark.parametrize('ignition', [False, True])
@pytest.mark.parametrize('gear', ['park', 'drive', 'reverse', 'neutral', 'unknown'])
def test_galaxy_requires_fresh_explicit_park_even_with_ignition_off(fixture, ignition, gear):
f = fixture
f.sm.values['pandaStates'][0].ignitionLine = ignition
f.sm.values['carState'].gearShifter = gear
assert f.source.status(require_park=True)['allowed'] == (gear == 'park')
f.sm.logMonoTime['carState'] = 1
assert not f.source.status(require_park=True)['allowed']
@@ -20,7 +20,7 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.drive = DriveStateOwner(self.params, Path(temporary.name) / 'drive', alive=lambda _: True)
self.drive.initialize(pid=1, birth=2, boot=BOOT)
self.allowed = True
self.physical = NS(allowed=lambda: self.allowed)
self.physical = NS(allowed=lambda: self.allowed, status=lambda **kwargs: {"allowed": self.allowed, "reason": None})
self.control = DriveStateControl(self.drive, self.physical, effective=lambda: False)
original = helpers.make_server
with patch.object(helpers, 'make_server', side_effect=lambda **kwargs: original(**kwargs, drive_state=self.control)):
@@ -41,17 +41,17 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.assertEqual((result[1]['mode'], result[1]['effective']), ('onroad', 'offroad'))
status = self.request('/api/drive-state/status', remote=True, cookie=self.remote_cookie)
self.assertEqual(status[1]['revision'], result[1]['revision'])
self.assertEqual(self.drive_action('offroad', remote=True)[0], 403)
self.assertEqual(self.drive_action('offroad', remote=True)[0], 200)
self.control.change('offroad', self.drive.snapshot().revision, lambda: True)
self.assertEqual(self.drive_action('auto', revision=result[1]['revision'])[0], 409)
def test_galaxy_blocks_offroad_but_onroad_and_auto_remain_recoverable(self):
self.assertEqual(self.drive_action('offroad')[0], 403)
def test_galaxy_requires_park_but_onroad_and_auto_remain_recoverable(self):
self.assertEqual(self.drive_action('offroad')[0], 200)
self.control.change('offroad', self.drive.snapshot().revision, lambda: True)
self.allowed = False
self.assertEqual(self.drive_action('onroad')[0], 200)
self.assertEqual(self.drive.snapshot().mode, Mode.ONROAD)
self.assertEqual(self.drive_action('offroad')[0], 403)
self.assertEqual(self.drive_action('offroad')[0], 409)
self.assertEqual(self.drive.snapshot().mode, Mode.ONROAD)
self.assertEqual(self.drive_action('auto', remote=True)[0], 200)
self.assertEqual(self.drive.snapshot().mode, Mode.AUTO)
@@ -62,7 +62,7 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.assertEqual(self.drive_action('onroad', origin='https://unrelated.invalid')[0], 403)
self.assertEqual(self.drive_action('badmode')[0], 409)
self.assertEqual(self.params.writes, before)
original_change = self.control.change
original_change = self.control.change_galaxy
def revoke_remote(mode, revision, authorized):
self.assertEqual(mode, 'onroad')
@@ -71,7 +71,7 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.assertFalse(authorized())
return original_change(mode, revision, authorized)
with patch.object(self.control, 'change', side_effect=revoke_remote):
with patch.object(self.control, 'change_galaxy', side_effect=revoke_remote):
self.assertEqual(self.drive_action('onroad', remote=True)[0], 409)
self.assertEqual(self.params.writes, before)
@@ -82,14 +82,15 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.control.change('offroad', self.drive.snapshot().revision, lambda: True)
self.assertEqual(self.drive.snapshot().mode, Mode.OFFROAD)
def test_galaxy_force_offroad_never_reaches_owner_for_local_or_remote_sessions(self):
def test_galaxy_offroad_rechecks_park_for_local_and_remote_sessions(self):
for remote in (False, True):
for allowed in (False, True):
self.allowed = allowed
before = list(self.params.writes)
with patch.object(self.control, 'change', side_effect=AssertionError('Galaxy must reject before dispatch')):
self.assertEqual(self.drive_action('offroad', remote=remote)[0], 403)
self.assertEqual(self.params.writes, before)
self.allowed = False
before = list(self.params.writes)
self.assertEqual(self.drive_action('offroad', remote=remote)[0], 409)
self.assertEqual(self.params.writes, before)
self.allowed = True
self.assertEqual(self.drive_action('offroad', remote=remote)[0], 200)
self.assertEqual(self.drive_action('auto', remote=remote)[0], 200)
def test_authenticated_force_offroad_from_live_onroad_state(self):
self._force_offroad_from_live_onroad_state(True)
@@ -117,7 +118,7 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.assertEqual(status[1]['effective'], 'onroad')
self.assertEqual(status[1]['overrideAllowed'], can_valid)
result = self.drive_action('offroad', remote=True)
self.assertEqual(result[0], 403)
self.assertEqual(result[0], 200 if can_valid else 409)
self.control.change('offroad', self.drive.snapshot().revision, lambda: True)
self.assertEqual(self.drive.snapshot().mode, Mode.OFFROAD)
# hardwared consumes this same owner request through the actual resolver.
+1 -4
View File
@@ -111,13 +111,10 @@ def change_drive_state(self, payload, effect_lock, drive_owner):
len(payload['revision']) != 32):
self.json(400, {'error': 'Invalid drive state request'})
return
if payload['mode'] == 'offroad':
self.json(403, {'error': 'Use the in-car Force Off-road control'})
return
from openpilot.starpilot.drive_state.owner import Rejected
try:
with effect_lock:
drive_owner().change(payload['mode'], payload['revision'], lambda: self.settings_session() == identity)
drive_owner().change_galaxy(payload['mode'], payload['revision'], lambda: self.settings_session() == identity)
result = drive_owner().snapshot()
except Rejected as error:
self.json(409, {'error': str(error)})
@@ -30,16 +30,14 @@ assert.equal(denied.error,'Drive state unavailable')
console.log('PASS: strict status; captured revision; aborted/out-of-order status; requested versus effective; unmount; unauthorized state')
const onroad=panel();onroad.state={...snapshot(),effective:'onroad',overrideAllowed:false}
globalThis.window={confirm:()=>false}
const before=requests.length
await onroad.change('offroad');assert.equal(requests.length,before)
window.confirm=()=>true
await onroad.change('offroad');assert.equal(requests.length,before)
onroad.state.overrideAllowed=true
await onroad.change('offroad');assert.equal(requests.length,before)
assert.match(DriveStatePanel.template, /\['onroad', 'auto'\]/)
assert.doesNotMatch(DriveStatePanel.template, /v-for="mode in \['offroad'/)
let before=requests.length
const forceOffroad=onroad.change('offroad')
assert.equal(requests.length,before+1)
assert.equal(JSON.parse(requests.at(-1).options.body).mode,'offroad')
requests.at(-1).resolve(response({error:'Park the vehicle'},409));await flush()
requests.at(-1).resolve(response(snapshot()));await forceOffroad;await flush()
assert.match(DriveStatePanel.template,/role="switch"/)
before=requests.length
// A missing parked admission must not disable Galaxy Force Onroad.
onroad.state.overrideAllowed=false
const forceOnroad=onroad.change('onroad')
@@ -50,4 +48,4 @@ requests.at(-1).resolve(response(live));await flush()
requests.at(-1).resolve(response(live));await forceOnroad;await flush()
assert.equal(onroad.state.mode,'onroad')
assert.equal(DriveStatePanel.computed.pending.call(onroad),false)
console.log('PASS: Galaxy has no Offroad action; direct JS Offroad dispatch is ignored; Force Onroad works without parked admission')
console.log('PASS: Galaxy Offroad dispatch uses server admission; Force Onroad remains available')
@@ -6,6 +6,7 @@ export const validDriveState = (value) => !!value && ["auto", "offroad", "onroad
export const DriveStatePanel = {
components: { GxNotice },
props: { offroadOnly: { type: Boolean, default: false } },
data: () => ({ state: null, error: "", busy: false }),
computed: {
pending() { return this.state?.mode !== "auto" && this.state?.effective !== this.state?.mode },
@@ -35,7 +36,7 @@ export const DriveStatePanel = {
},
async change(mode) {
if (this.busy || !this.state?.available) return
if (!["onroad", "auto"].includes(mode)) return
if (!["onroad", "offroad", "auto"].includes(mode)) return
const revision = this.state.revision
this.busy = true
this.loading = false
@@ -59,8 +60,12 @@ export const DriveStatePanel = {
<GxNotice tone="danger" v-if="state && !state.available">The drive-state manager is unavailable. Reconnect to check again.</GxNotice>
<GxNotice v-if="pending" tone="info">Waiting for the device to switch {{ state.mode }}…</GxNotice>
<p>Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
<p>Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation.</p>
<div class="gx-force-drive__actions gx-actions"><button v-for="mode in ['onroad', 'auto']" :key="mode" type="button"
<p v-if="!offroadOnly">Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation.</p>
<label v-if="offroadOnly" class="gx-row"><span>Force Offroad</span><input type="checkbox" role="switch"
:checked="state?.mode === 'offroad'" :disabled="!state?.available"
@change="change($event.target.checked ? 'offroad' : 'auto')" /></label>
<p v-if="offroadOnly" class="gx-note">Park the vehicle and disengage controls to enable. Turn off to restore automatic drive state.</p>
<div v-if="!offroadOnly" class="gx-force-drive__actions gx-actions"><button v-for="mode in ['onroad', 'auto']" :key="mode" type="button"
class="gx-btn" :class="state?.mode === mode ? '' : 'gx-btn--tonal'"
:disabled="!state?.available"
:aria-pressed="state?.mode === mode" @click="change(mode)">{{ mode === 'auto' ? 'Return to Auto' : 'Onroad' }}</button></div>
@@ -1,3 +1,4 @@
import { DriveStatePanel } from "./drive-state.js"
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
@@ -316,7 +317,7 @@ export class SoftwareStatusFeed {
}
export const SoftwarePage = {
components: { GxDialog, GxState, GxNotice, GalaxySelect, GalaxySettingRow, ToggleBackup },
components: { DriveStatePanel, GxDialog, GxState, GxNotice, GalaxySelect, GalaxySettingRow, ToggleBackup },
name: "SoftwarePage",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, restarting: { type: Function, default: () => {} },
preparing: { type: Function, default: () => {} }, cancelPreparation: { type: Function, default: () => {} } },
@@ -531,7 +532,7 @@ export const SoftwarePage = {
:index="0" :busy="busy" :disabled="uncertain || !!error || !operations.canConfigure"
:save-value="(index, value) => feed.configureAutomaticDownloads(value === 'On')" />
<p v-if="operations?.automaticDownloads === null" class="gx-note">The download preference could not be read. Choose a setting while parked to repair it.</p>
<details v-if="data" class="gx-software-options"><summary>Advanced options</summary><div class="gx-stack">
<details v-if="data" class="gx-software-options"><summary>Advanced options</summary><div class="gx-stack"><DriveStatePanel offroad-only />
<div class="gx-software-branch"><label class="gx-field-group gx-grow"><span class="gx-row__label">Branch</span>
<GalaxySelect v-model="primaryChoice" class="gx-field gx-field--full" aria-label="Target branch" :disabled="actionUnavailable || !operations.canSelect" @change="onPrimaryBranchChange">
<option value="" disabled>Choose a branch</option>
+2 -1
View File
@@ -121,7 +121,8 @@ class SpeedLimitWidget:
centered(shown.offset_text, FontRole.MEDIUM, size, offset_top, color)
else:
rect = rl.Rectangle(x + 2 * scale, top + 2 * scale, 114 * scale, 128 * scale)
rl.draw_rectangle_rounded(rect, .1, 16, rl.Color(10, 17, 19, 230))
if self.profile == Profile.LARGE:
rl.draw_rectangle_rounded(rect, .1, 16, rl.Color(10, 17, 19, 230))
rl.draw_rectangle_rounded_lines_ex(rect, .1, 16, 2 * scale, white)
for label, y in (('SPEED', top + 11 * scale), ('LIMIT', top + 27 * scale)):
centered(label, FontRole.SEMI_BOLD, 20, y, white, x + (53 if arrow_direction else 59) * scale)
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "bde512e64cf3c779bbba6f7d3b24e8733a168129e314fe6aa2144ccdd030c827", "schema_version": 1}
{"protected_inventory_sha256": "9d2ea7161b8e2925fa3c7e55d07bea76744c4ae40d1e0926ae03b0d0acfe22a1", "schema_version": 1}