From d69a9f4db56d96db050efae97981902f0e0621ae Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:52:58 -0500 Subject: [PATCH] Restore Small UI sign and parked Galaxy offroad --- openpilot/starpilot/drive_state/control.py | 9 +++++- openpilot/starpilot/drive_state/evidence.py | 4 +-- .../drive_state/tests/test_evidence.py | 11 +++++++ .../starpilot/drive_state/tests/test_http.py | 31 ++++++++++--------- openpilot/starpilot/galaxy/batch_routes.py | 5 +-- .../galaxy/tests/test_drive_state.mjs | 20 ++++++------ .../starpilot/galaxy/web/js/drive-state.js | 11 +++++-- .../galaxy/web/js/software-status.js | 5 +-- openpilot/starpilot/ui/speed_limit_widget.py | 3 +- prebuilt.json | 2 +- 10 files changed, 61 insertions(+), 40 deletions(-) diff --git a/openpilot/starpilot/drive_state/control.py b/openpilot/starpilot/drive_state/control.py index ed658fd296..6972bff4f9 100644 --- a/openpilot/starpilot/drive_state/control.py +++ b/openpilot/starpilot/drive_state/control.py @@ -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']) diff --git a/openpilot/starpilot/drive_state/evidence.py b/openpilot/starpilot/drive_state/evidence.py index 7451400261..3b7a77f5d7 100644 --- a/openpilot/starpilot/drive_state/evidence.py +++ b/openpilot/starpilot/drive_state/evidence.py @@ -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): diff --git a/openpilot/starpilot/drive_state/tests/test_evidence.py b/openpilot/starpilot/drive_state/tests/test_evidence.py index b8f9af2c24..4dc31fbe41 100644 --- a/openpilot/starpilot/drive_state/tests/test_evidence.py +++ b/openpilot/starpilot/drive_state/tests/test_evidence.py @@ -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'] diff --git a/openpilot/starpilot/drive_state/tests/test_http.py b/openpilot/starpilot/drive_state/tests/test_http.py index 36ae119bf1..3b3bf55a1a 100644 --- a/openpilot/starpilot/drive_state/tests/test_http.py +++ b/openpilot/starpilot/drive_state/tests/test_http.py @@ -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. diff --git a/openpilot/starpilot/galaxy/batch_routes.py b/openpilot/starpilot/galaxy/batch_routes.py index 0fc6ab91f7..d0a3a1e256 100644 --- a/openpilot/starpilot/galaxy/batch_routes.py +++ b/openpilot/starpilot/galaxy/batch_routes.py @@ -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)}) diff --git a/openpilot/starpilot/galaxy/tests/test_drive_state.mjs b/openpilot/starpilot/galaxy/tests/test_drive_state.mjs index 1701dcd00e..61485cf47b 100644 --- a/openpilot/starpilot/galaxy/tests/test_drive_state.mjs +++ b/openpilot/starpilot/galaxy/tests/test_drive_state.mjs @@ -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') diff --git a/openpilot/starpilot/galaxy/web/js/drive-state.js b/openpilot/starpilot/galaxy/web/js/drive-state.js index 48548c1970..19701247fb 100644 --- a/openpilot/starpilot/galaxy/web/js/drive-state.js +++ b/openpilot/starpilot/galaxy/web/js/drive-state.js @@ -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 = { The drive-state manager is unavailable. Reconnect to check again. Waiting for the device to switch {{ state.mode }}…

Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}

-

Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation.

-
diff --git a/openpilot/starpilot/galaxy/web/js/software-status.js b/openpilot/starpilot/galaxy/web/js/software-status.js index 53be3b6620..ee65179836 100644 --- a/openpilot/starpilot/galaxy/web/js/software-status.js +++ b/openpilot/starpilot/galaxy/web/js/software-status.js @@ -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')" />

The download preference could not be read. Choose a setting while parked to repair it.

-
Advanced options
+
Advanced options