Sync: commaai/openpilot:mastersunnypilot/sunnypilot:master (#1727)

This commit is contained in:
Jason Wen
2026-02-27 17:42:08 -05:00
committed by GitHub
55 changed files with 1320 additions and 517 deletions

View File

@@ -1,55 +0,0 @@
name: 'automatically cache based on current runner'
inputs:
path:
description: 'path to cache'
required: true
key:
description: 'key'
required: true
restore-keys:
description: 'restore-keys'
required: true
save:
description: 'whether to save the cache'
default: 'true'
required: false
outputs:
cache-hit:
description: 'cache hit occurred'
value: ${{ (contains(runner.name, 'nsc') && steps.ns-cache.outputs.cache-hit) ||
(!contains(runner.name, 'nsc') && inputs.save != 'false' && steps.gha-cache.outputs.cache-hit) ||
(!contains(runner.name, 'nsc') && inputs.save == 'false' && steps.gha-cache-ro.outputs.cache-hit) }}
runs:
using: "composite"
steps:
- name: setup namespace cache
id: ns-cache
if: ${{ contains(runner.name, 'nsc') }}
uses: namespacelabs/nscloud-cache-action@v1
with:
path: ${{ inputs.path }}
- name: setup github cache
id: gha-cache
if: ${{ !contains(runner.name, 'nsc') && inputs.save != 'false' }}
uses: 'actions/cache@v4'
with:
path: ${{ inputs.path }}
key: ${{ inputs.key }}
restore-keys: ${{ inputs.restore-keys }}
- name: setup github cache
id: gha-cache-ro
if: ${{ !contains(runner.name, 'nsc') && inputs.save == 'false' }}
uses: 'actions/cache/restore@v4'
with:
path: ${{ inputs.path }}
key: ${{ inputs.key }}
restore-keys: ${{ inputs.restore-keys }}
# make the directory manually in case we didn't get a hit, so it doesn't fail on future steps
- id: scons-cache-setup
shell: bash
run: mkdir -p ${{ inputs.path }}

View File

@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Push badges
run: |
python3 selfdrive/ui/translations/create_badges.py

View File

@@ -30,7 +30,7 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons -j$(nproc) cereal
- name: Generate the log file
@@ -62,7 +62,7 @@ jobs:
path: openpilot
submodules: true
ref: "refs/heads/master"
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Build openpilot
working-directory: openpilot
run: scons -j$(nproc) cereal

View File

@@ -1,21 +0,0 @@
name: 'compile openpilot'
runs:
using: "composite"
steps:
- shell: bash
name: Build openpilot with all flags
run: |
scons -j$(nproc)
release/check-dirty.sh
- shell: bash
name: Cleanup scons cache and rebuild
run: |
rm -rf /tmp/scons_cache/*
scons -j$(nproc) --cache-populate
- name: Save scons cache
uses: actions/cache/save@v4
if: github.ref == 'refs/heads/master'
with:
path: /tmp/scons_cache
key: scons-${{ runner.arch }}-${{ env.CACHE_COMMIT_DATE }}-${{ github.sha }}

View File

@@ -17,6 +17,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: true
- name: Checkout master
uses: actions/checkout@v6
with:
@@ -25,14 +27,12 @@ jobs:
- run: git lfs pull
- run: cd base && git lfs pull
- run: pip install onnx
- name: scripts/reporter.py
id: report
run: |
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "## Model Review" >> $GITHUB_OUTPUT
MASTER_PATH=${{ github.workspace }}/base python scripts/reporter.py >> $GITHUB_OUTPUT
PYTHONPATH=${{ github.workspace }} MASTER_PATH=${{ github.workspace }}/base python scripts/reporter.py >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Post model report comment

View File

@@ -26,6 +26,6 @@ jobs:
with:
submodules: true
fetch-depth: 0
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Push __nightly
run: BRANCH=__nightly release/build_stripped.sh

View File

@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Update translations
run: python3 selfdrive/ui/update_translations.py --vanish
- name: Create Pull Request
@@ -39,7 +39,7 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: uv lock
run: uv lock --upgrade
- name: uv pip tree

View File

@@ -1,48 +0,0 @@
name: 'openpilot env setup, with retry on failure'
inputs:
sleep_time:
description: 'Time to sleep between retries'
required: false
default: 30
outputs:
duration:
description: 'Duration of the setup process in seconds'
value: ${{ steps.get_duration.outputs.duration }}
runs:
using: "composite"
steps:
- id: start_time
shell: bash
run: echo "START_TIME=$(date +%s)" >> $GITHUB_ENV
- id: setup1
uses: ./.github/workflows/setup
continue-on-error: true
with:
is_retried: true
- if: steps.setup1.outcome == 'failure'
shell: bash
run: sleep ${{ inputs.sleep_time }}
- id: setup2
if: steps.setup1.outcome == 'failure'
uses: ./.github/workflows/setup
continue-on-error: true
with:
is_retried: true
- if: steps.setup2.outcome == 'failure'
shell: bash
run: sleep ${{ inputs.sleep_time }}
- id: setup3
if: steps.setup2.outcome == 'failure'
uses: ./.github/workflows/setup
with:
is_retried: true
- id: get_duration
shell: bash
run: |
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "Total duration: $DURATION seconds"
echo "duration=$DURATION" >> $GITHUB_OUTPUT

View File

@@ -1,56 +0,0 @@
name: 'openpilot env setup'
inputs:
is_retried:
description: 'A mock param that asserts that we use the setup-with-retry instead of this action directly'
required: false
default: 'false'
runs:
using: "composite"
steps:
# assert that this action is retried using the setup-with-retry
- shell: bash
if: ${{ inputs.is_retried == 'false' }}
run: |
echo "You should not run this action directly. Use setup-with-retry instead"
exit 1
- shell: bash
name: No retries!
run: |
if [ "${{ github.run_attempt }}" -gt ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.author_association == 'NONE' && 2 || 1}} ]; then
echo -e "\033[0;31m##################################################"
echo -e "\033[0;31m Retries not allowed! Fix the flaky test! "
echo -e "\033[0;31m##################################################\033[0m"
exit 1
fi
# build cache
- id: date
shell: bash
run: echo "CACHE_COMMIT_DATE=$(git log -1 --pretty='format:%cd' --date=format:'%Y-%m-%d-%H:%M')" >> $GITHUB_ENV
- id: scons-cache
uses: ./.github/workflows/auto-cache
with:
path: /tmp/scons_cache
key: scons-${{ runner.os }}-${{ runner.arch }}-${{ env.CACHE_COMMIT_DATE }}-${{ github.sha }}
restore-keys: |
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.CACHE_COMMIT_DATE }}
scons-${{ runner.os }}-${{ runner.arch }}
# venv cache
- id: venv-cache
uses: ./.github/workflows/auto-cache
with:
path: ${{ github.workspace }}/.venv
key: venv-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock') }}
restore-keys: |
venv-${{ runner.os }}-${{ runner.arch }}
- shell: bash
name: Run setup
run: ./tools/op.sh setup
- shell: bash
name: Setup cache dirs
run: |
mkdir -p /tmp/comma_download_cache
echo "$GITHUB_WORKSPACE/.venv/bin" >> $GITHUB_PATH

View File

@@ -27,7 +27,7 @@ jobs:
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]')
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
env:
STRIPPED_DIR: /tmp/releasepilot
@@ -45,7 +45,7 @@ jobs:
- name: Build devel
timeout-minutes: 1
run: TARGET_DIR=$STRIPPED_DIR release/build_stripped.sh
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Build openpilot and run checks
timeout-minutes: 30
working-directory: ${{ env.STRIPPED_DIR }}
@@ -86,7 +86,7 @@ jobs:
run: |
FILTERED=$(echo "$PATH" | tr ':' '\n' | grep -v '/opt/homebrew' | tr '\n' ':')
echo "PATH=${FILTERED}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" >> $GITHUB_ENV
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Building openpilot
run: scons
@@ -96,13 +96,13 @@ jobs:
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]')
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Static analysis
timeout-minutes: 1
run: scripts/lint/lint.sh
@@ -113,18 +113,17 @@ jobs:
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]')
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
id: setup-step
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons -j$(nproc)
- name: Run unit tests
timeout-minutes: ${{ contains(runner.name, 'nsc') && ((steps.setup-step.outputs.duration < 18) && 1 || 2) || 999 }}
timeout-minutes: ${{ contains(runner.name, 'nsc') && 2 || 999 }}
run: |
source selfdrive/test/setup_xvfb.sh
# Pre-compile Python bytecode so each pytest worker doesn't need to
@@ -138,24 +137,17 @@ jobs:
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]')
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
id: setup-step
- name: Cache test routes
id: dependency-cache
uses: actions/cache@v5
with:
path: /tmp/comma_download_cache
key: proc-replay-${{ hashFiles('selfdrive/test/process_replay/test_processes.py') }}
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons -j$(nproc)
- name: Run replay
timeout-minutes: ${{ contains(runner.name, 'nsc') && (steps.dependency-cache.outputs.cache-hit == 'true') && ((steps.setup-step.outputs.duration < 18) && 1 || 2) || 20 }}
timeout-minutes: ${{ contains(runner.name, 'nsc') && 2 || 20 }}
continue-on-error: ${{ github.ref == 'refs/heads/master' }}
run: selfdrive/test/process_replay/test_processes.py -j$(nproc)
- name: Print diff
@@ -179,15 +171,15 @@ jobs:
if: github.repository == 'commaai/openpilot' && github.ref == 'refs/heads/master'
working-directory: ${{ github.workspace }}/ci-artifacts
run: |
git checkout --orphan process-replay
git rm -rf .
git config user.name "GitHub Actions Bot"
git config user.email "<>"
git fetch origin process-replay || true
git checkout process-replay 2>/dev/null || git checkout --orphan process-replay
cp ${{ github.workspace }}/selfdrive/test/process_replay/fakedata/*.zst .
echo "${{ github.sha }}" > ref_commit
git add .
git commit -m "process-replay refs for ${{ github.repository }}@${{ github.sha }}"
git push origin process-replay --force
git commit -m "process-replay refs for ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
git push origin process-replay
- name: Run regen
if: false
timeout-minutes: 4
@@ -201,19 +193,18 @@ jobs:
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]')
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
if: false # FIXME: Started to timeout recently
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
id: setup-step
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons -j$(nproc)
- name: Driving test
timeout-minutes: ${{ (steps.setup-step.outputs.duration < 18) && 1 || 2 }}
timeout-minutes: 2
run: |
source selfdrive/test/setup_xvfb.sh
pytest -s tools/sim/tests/test_metadrive_bridge.py
@@ -224,13 +215,13 @@ jobs:
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]')
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: ./.github/workflows/setup-with-retry
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons -j$(nproc)
- name: Create UI Report

View File

@@ -18,6 +18,7 @@ AddOption('--asan', action='store_true', help='turn on ASAN')
AddOption('--ubsan', action='store_true', help='turn on UBSan')
AddOption('--mutation', action='store_true', help='generate mutation-ready code')
AddOption('--ccflags', action='store', type='string', default='', help='pass arbitrary flags over the command line')
AddOption('--verbose', action='store_true', default=False, help='show full build commands')
AddOption('--minimal',
action='store_false',
dest='extras',
@@ -38,6 +39,7 @@ assert arch in [
]
if arch != "larch64":
import bzip2
import capnproto
import eigen
import ffmpeg as ffmpeg_pkg
@@ -47,7 +49,7 @@ if arch != "larch64":
import python3_dev
import zeromq
import zstd
pkgs = [capnproto, eigen, ffmpeg_pkg, libjpeg, ncurses, openssl3, zeromq, zstd]
pkgs = [bzip2, capnproto, eigen, ffmpeg_pkg, libjpeg, ncurses, openssl3, zeromq, zstd]
py_include = python3_dev.INCLUDE_DIR
else:
# TODO: remove when AGNOS has our new vendor pkgs
@@ -148,6 +150,22 @@ if _extra_cc:
if arch != "Darwin":
env.Append(LINKFLAGS=["-Wl,--as-needed", "-Wl,--no-undefined"])
# Shorter build output: show brief descriptions instead of full commands.
# Full command lines are still printed on failure by scons.
if not GetOption('verbose'):
for action, short in (
("CC", "CC"),
("CXX", "CXX"),
("LINK", "LINK"),
("SHCC", "CC"),
("SHCXX", "CXX"),
("SHLINK", "LINK"),
("AR", "AR"),
("RANLIB", "RANLIB"),
("AS", "AS"),
):
env[f"{action}COMSTR"] = f" [{short}] $TARGET"
# progress output
node_interval = 5
node_count = 0

View File

@@ -592,6 +592,7 @@ struct PandaState @0xa7649e2575e4591e {
harnessStatus @21 :HarnessStatus;
sbu1Voltage @35 :Float32;
sbu2Voltage @36 :Float32;
soundOutputLevel @37 :UInt16;
# can health
canState0 @29 :PandaCanState;

View File

@@ -8,7 +8,7 @@ NOTE: Those commands must be run in the root directory of openpilot, **not /docs
**1. Install the docs dependencies**
``` bash
pip install .[docs]
uv pip install .[docs]
```
**2. Build the new site**

View File

@@ -21,10 +21,10 @@ Each car brand is supported by a standard interface structure in `opendbc/car/[b
* `values.py`: Limits for actuation, general constants for cars, and supported car documentation
* `radar_interface.py`: Interface for parsing radar points from the car, if applicable
## panda
## safety
* `board/safety/safety_[brand].h`: Brand-specific safety logic
* `tests/safety/test_[brand].py`: Brand-specific safety CI tests
* `opendbc_repo/opendbc/safety/modes/[brand].h`: Brand-specific safety logic
* `opendbc_repo/opendbc/safety/tests/test_[brand].py`: Brand-specific safety CI tests
## openpilot

2
panda

Submodule panda updated: a0d3a4abe1...a4e30942fa

View File

@@ -26,6 +26,7 @@ dependencies = [
"numpy >=2.0",
# vendored native dependencies
"bzip2 @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=bzip2",
"capnproto @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=capnproto",
"eigen @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=eigen",
"ffmpeg @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=ffmpeg",

View File

@@ -25,6 +25,7 @@ MIN_ABS_YAW_RATE = 0.0
MAX_YAW_RATE_SANITY_CHECK = 1.0
MIN_NCC = 0.95
MAX_LAG = 1.0
MIN_LAG = 0.15
MAX_LAG_STD = 0.1
MAX_LAT_ACCEL = 2.0
MAX_LAT_ACCEL_DIFF = 0.6
@@ -216,7 +217,7 @@ class LateralLagEstimator:
liveDelay.status = log.LiveDelayData.Status.unestimated
if liveDelay.status == log.LiveDelayData.Status.estimated:
liveDelay.lateralDelay = valid_mean_lag
liveDelay.lateralDelay = min(MAX_LAG, max(MIN_LAG, valid_mean_lag))
else:
liveDelay.lateralDelay = self.initial_lag
@@ -299,7 +300,7 @@ class LateralLagEstimator:
new_values_start_idx = next(-i for i, t in enumerate(reversed(times)) if t <= self.last_estimate_t)
is_valid = is_valid and not (new_values_start_idx == 0 or not np.any(okay[new_values_start_idx:]))
delay, corr, confidence = self.actuator_delay(desired, actual, okay, self.dt, MAX_LAG)
delay, corr, confidence = self.actuator_delay(desired, actual, okay, self.dt, MIN_LAG, MAX_LAG)
if corr < self.min_ncc or confidence < self.min_confidence or not is_valid:
return
@@ -307,22 +308,23 @@ class LateralLagEstimator:
self.last_estimate_t = self.t
@staticmethod
def actuator_delay(expected_sig: np.ndarray, actual_sig: np.ndarray, mask: np.ndarray, dt: float, max_lag: float) -> tuple[float, float, float]:
def actuator_delay(expected_sig: np.ndarray, actual_sig: np.ndarray, mask: np.ndarray,
dt: float, min_lag: float, max_lag: float) -> tuple[float, float, float]:
assert len(expected_sig) == len(actual_sig)
max_lag_samples = int(max_lag / dt)
min_lag_samples, max_lag_samples = int(round(min_lag / dt)), int(round(max_lag / dt))
padded_size = fft_next_good_size(len(expected_sig) + max_lag_samples)
ncc = masked_normalized_cross_correlation(expected_sig, actual_sig, mask, padded_size)
# only consider lags from 0 to max_lag
roi = np.s_[len(expected_sig) - 1: len(expected_sig) - 1 + max_lag_samples]
# only consider lags from min_lag to max_lag
roi = np.s_[len(expected_sig) - 1 + min_lag_samples: len(expected_sig) - 1 + max_lag_samples]
extended_roi = np.s_[roi.start - CORR_BORDER_OFFSET: roi.stop + CORR_BORDER_OFFSET]
roi_ncc = ncc[roi]
extended_roi_ncc = ncc[extended_roi]
max_corr_index = np.argmax(roi_ncc)
corr = roi_ncc[max_corr_index]
lag = parabolic_peak_interp(roi_ncc, max_corr_index) * dt
lag = parabolic_peak_interp(roi_ncc, max_corr_index) * dt + min_lag
# to estimate lag confidence, gather all high-correlation candidates and see how spread they are
# if e.g. 0.8 and 0.4 are both viable, this is an ambiguous case

View File

@@ -97,7 +97,7 @@ class TestLagd:
assert msg.liveDelay.calPerc == 0
def test_estimator_basics(self, subtests):
for lag_frames in range(5):
for lag_frames in range(3, 10):
with subtests.test(msg=f"lag_frames={lag_frames}"):
mocked_CP = car.CarParams(steerActuatorDelay=0.8)
estimator = LateralLagEstimator(mocked_CP, DT, min_recovery_buffer_sec=0.0, min_yr=0.0)
@@ -111,7 +111,7 @@ class TestLagd:
assert msg.liveDelay.calPerc == 100
def test_estimator_masking(self):
mocked_CP, lag_frames = car.CarParams(steerActuatorDelay=0.8), random.randint(1, 19)
mocked_CP, lag_frames = car.CarParams(steerActuatorDelay=0.8), random.randint(3, 19)
estimator = LateralLagEstimator(mocked_CP, DT, min_recovery_buffer_sec=0.0, min_yr=0.0, min_valid_block_count=1)
process_messages(estimator, lag_frames, (int(MIN_OKAY_WINDOW_SEC / DT) + BLOCK_SIZE) * 2, rejection_threshold=0.4)
msg = estimator.get_msg(True)

View File

@@ -35,7 +35,7 @@ class DRIVER_MONITOR_SETTINGS:
self._EYE_THRESHOLD = 0.65
self._SG_THRESHOLD = 0.9
self._BLINK_THRESHOLD = 0.865
self._PHONE_THRESH = 0.68
self._PHONE_THRESH = 0.5
self._POSE_PITCH_THRESHOLD = 0.3133
self._POSE_PITCH_THRESHOLD_SLACK = 0.3237

View File

@@ -151,6 +151,7 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda
ps.setSpiErrorCount(health.spi_error_count_pkt);
ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f);
ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f);
ps.setSoundOutputLevel(health.sound_output_level_pkt);
}
void fill_panda_can_state(cereal::PandaState::PandaCanState::Builder &cs, const can_health_t &can_health) {

View File

@@ -17,9 +17,9 @@ if gui_app.sunnypilot_ui():
ONROAD_DELAY = 2.5 # seconds
class MiciMainLayout(Widget):
class MiciMainLayout(Scroller):
def __init__(self):
super().__init__()
super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
self._pm = messaging.PubMaster(['bookmarkButton'])
@@ -39,13 +39,12 @@ class MiciMainLayout(Widget):
# TODO: set parent rect and use it if never passed rect from render (like in Scroller)
widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
self._scroller = Scroller([
self._scroller.add_widgets([
self._alerts_layout,
self._home_layout,
self._onroad_layout,
], snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
])
self._scroller.set_reset_scroll_at_show(False)
self._scroller.set_enabled(lambda: self.enabled) # for nav stack
# Disable scrolling when onroad is interacting with bookmark
self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left())
@@ -65,14 +64,6 @@ class MiciMainLayout(Widget):
self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
device.add_interactive_timeout_callback(self._on_interactive_timeout)
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _scroll_to(self, layout: Widget):
layout_x = int(layout.rect.x)
self._scroller.scroll_to(layout_x, smooth=True)
@@ -86,7 +77,7 @@ class MiciMainLayout(Widget):
self._setup = True
# Render
self._scroller.render(self._rect)
super()._render(self._rect)
self._handle_transitions()

View File

@@ -186,19 +186,17 @@ class AlertItem(Widget):
rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE)
class MiciOffroadAlerts(Widget):
class MiciOffroadAlerts(Scroller):
"""Offroad alerts layout with vertical scrolling."""
def __init__(self):
super().__init__()
# Create vertical scroller
super().__init__(horizontal=False, spacing=12, pad=0)
self.params = Params()
self.sorted_alerts: list[AlertData] = []
self.alert_items: list[AlertItem] = []
self._last_refresh = 0.0
# Create vertical scroller
self._scroller = Scroller([], horizontal=False, spacing=12, pad=0)
# Create empty state label
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
@@ -290,14 +288,9 @@ class MiciOffroadAlerts(Widget):
def show_event(self):
"""Reset scroll position when shown and refresh alerts."""
super().show_event()
self._scroller.show_event()
self._last_refresh = time.monotonic()
self.refresh()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _update_state(self):
"""Periodically refresh alerts."""
# Refresh alerts periodically, not every frame

View File

@@ -1,17 +1,14 @@
import pyray as rl
from openpilot.common.time_helpers import system_time_valid
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl, BigCircleParamControl
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction
class DeveloperLayoutMici(NavWidget):
class DeveloperLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self.set_back_callback(gui_app.pop_widget)
@@ -25,10 +22,14 @@ class DeveloperLayoutMici(NavWidget):
else:
dlg = BigDialog("", ssh_keys._error_message)
gui_app.push_widget(dlg)
else:
ui_state.params.remove("GithubUsername")
ui_state.params.remove("GithubSshKeys")
self._ssh_keys_btn.set_value("Not set")
def ssh_keys_callback():
github_username = ui_state.params.get("GithubUsername") or ""
dlg = BigInputDialog("enter GitHub username...", github_username, confirm_callback=github_username_callback)
dlg = BigInputDialog("enter GitHub username...", github_username, minimum_length=0, confirm_callback=github_username_callback)
if not system_time_valid():
dlg = BigDialog("Please connect to Wi-Fi to fetch your key", "")
gui_app.push_widget(dlg)
@@ -57,7 +58,7 @@ class DeveloperLayoutMici(NavWidget):
toggle_callback=lambda checked: (gui_app.set_show_touches(checked),
gui_app.set_show_fps(checked)))
self._scroller = Scroller([
self._scroller.add_widgets([
self._adb_toggle,
self._ssh_toggle,
self._ssh_keys_btn,
@@ -101,16 +102,8 @@ class DeveloperLayoutMici(NavWidget):
def show_event(self):
super().show_event()
self._scroller.show_event()
self._update_toggles()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)
def _update_toggles(self):
ui_state.update_params()

View File

@@ -7,7 +7,7 @@ from collections.abc import Callable
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.time_helpers import system_time_valid
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2
@@ -32,6 +32,7 @@ class MiciFccModal(NavWidget):
self.set_back_callback(gui_app.pop_widget)
self._content = HtmlRenderer(file_path=file_path, text=text)
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scroll_panel.set_enabled(lambda: self.enabled and not self._swiping_away)
self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64)
def _render(self, rect: rl.Rectangle):
@@ -266,7 +267,7 @@ class UpdateOpenpilotBigButton(BigButton):
self._waiting_for_updater_t = None
class DeviceLayoutMici(NavWidget):
class DeviceLayoutMici(NavScroller):
def __init__(self):
super().__init__()
@@ -313,7 +314,7 @@ class DeviceLayoutMici(NavWidget):
review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(TrainingGuide(completed_callback=gui_app.pop_widget)))
review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad())
self._scroller = Scroller([
self._scroller.add_widgets([
DeviceInfoLayoutMici(),
UpdateOpenpilotBigButton(),
PairBigButton(),
@@ -340,14 +341,3 @@ class DeviceLayoutMici(NavWidget):
def _offroad_transition(self):
self._power_off_btn.set_visible(ui_state.is_offroad())
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)

View File

@@ -224,3 +224,4 @@ class FirehoseLayout(FirehoseLayoutBase, NavWidget):
def __init__(self):
super().__init__()
self.set_back_callback(gui_app.pop_widget)
self._scroll_panel.set_enabled(lambda: self.enabled and not self._swiping_away)

View File

@@ -1,13 +1,12 @@
import pyray as rl
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici, WifiIcon
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType, ConnectStatus, SecurityType, normalize_ssid
@@ -33,7 +32,7 @@ class WifiNetworkButton(BigButton):
display_network = next((n for n in self._wifi_manager.networks if n.ssid == wifi_state.ssid), None)
if wifi_state.status == ConnectStatus.CONNECTING:
self.set_text(normalize_ssid(wifi_state.ssid or "wi-fi"))
self.set_value("connecting...")
self.set_value("starting" if self._wifi_manager.is_tethering_active() else "connecting...")
elif wifi_state.status == ConnectStatus.CONNECTED:
self.set_text(normalize_ssid(wifi_state.ssid or "wi-fi"))
self.set_value(self._wifi_manager.ipv4_address or "obtaining IP...")
@@ -46,6 +45,10 @@ class WifiNetworkButton(BigButton):
strength = WifiIcon.get_strength_icon_idx(display_network.strength)
self.set_icon(self._wifi_full_txt if strength == 2 else self._wifi_medium_txt if strength == 1 else self._wifi_low_txt)
self._draw_lock = display_network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED)
elif self._wifi_manager.is_tethering_active():
# takes a while to get Network
self.set_icon(self._wifi_full_txt)
self._draw_lock = True
else:
self.set_icon(self._wifi_slash_txt)
self._draw_lock = False
@@ -61,7 +64,7 @@ class WifiNetworkButton(BigButton):
rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE)
class NetworkLayoutMici(NavWidget):
class NetworkLayoutMici(NavScroller):
def __init__(self):
super().__init__()
@@ -128,7 +131,7 @@ class NetworkLayoutMici(NavWidget):
self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", toggle_callback=self._toggle_cellular_metered)
# Main scroller ----------------------------------
self._scroller = Scroller([
self._scroller.add_widgets([
self._wifi_button,
self._network_metered_btn,
self._tethering_toggle_btn,
@@ -161,14 +164,12 @@ class NetworkLayoutMici(NavWidget):
def show_event(self):
super().show_event()
self._wifi_manager.set_active(True)
self._scroller.show_event()
# Process wifi callbacks while at any point in the nav stack
gui_app.set_nav_stack_tick(self._wifi_manager.process_callbacks)
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
self._wifi_manager.set_active(False)
gui_app.set_nav_stack_tick(None)
@@ -209,6 +210,3 @@ class NetworkLayoutMici(NavWidget):
MeteredType.YES: 'metered',
MeteredType.NO: 'unmetered'
}.get(self._wifi_manager.current_network_metered, 'default'))
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)

View File

@@ -6,11 +6,10 @@ from collections.abc import Callable
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialogV2
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR, LABEL_HORIZONTAL_PADDING, LABEL_VERTICAL_PADDING
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid
@@ -98,7 +97,7 @@ class WifiIcon(Widget):
class WifiButton(BigButton):
LABEL_PADDING = 98
LABEL_WIDTH = 402 - 98 - 28 # button width - left padding - right padding
SUB_LABEL_WIDTH = 402 - LABEL_HORIZONTAL_PADDING * 2
SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2
def __init__(self, network: Network, wifi_manager: WifiManager):
super().__init__(normalize_ssid(network.ssid), scroll=True)
@@ -166,13 +165,13 @@ class WifiButton(BigButton):
def _draw_content(self, btn_y: float):
self._label.set_color(LABEL_COLOR)
label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + LABEL_VERTICAL_PADDING,
self.LABEL_WIDTH, self._rect.height - LABEL_VERTICAL_PADDING * 2)
label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING,
self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
if self.value:
sub_label_x = self._rect.x + LABEL_HORIZONTAL_PADDING
label_y = btn_y + self._rect.height - LABEL_VERTICAL_PADDING
sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING
sub_label_w = self.SUB_LABEL_WIDTH - (self._forget_btn.rect.width if self._show_forget_btn else 0)
sub_label_height = self._sub_label.get_content_height(sub_label_w)
@@ -227,9 +226,9 @@ class WifiButton(BigButton):
if self._network_forgetting:
self.set_value("forgetting...")
elif self._is_connecting:
self.set_value("connecting...")
self.set_value("starting..." if self._network.is_tethering else "connecting...")
elif self._is_connected:
self.set_value("connected")
self.set_value("tethering" if self._network.is_tethering else "connected")
elif self._network_missing:
# after connecting/connected since NM will still attempt to connect/stay connected for a while
self.set_value("not in range")
@@ -271,15 +270,13 @@ class ForgetButton(Widget):
rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE)
class WifiUIMici(NavWidget):
class WifiUIMici(NavScroller):
def __init__(self, wifi_manager: WifiManager):
super().__init__()
# Set up back navigation
self.set_back_callback(gui_app.pop_widget)
self._scroller = Scroller([])
self._loading_animation = LoadingAnimation()
self._wifi_manager = wifi_manager
@@ -294,17 +291,12 @@ class WifiUIMici(NavWidget):
def show_event(self):
# Clear scroller items and update from latest scan results
super().show_event()
self._scroller.show_event()
self._loading_animation.show_event()
self._wifi_manager.set_active(True)
self._scroller.items.clear()
# trigger button update on latest sorted networks
self._on_network_updated(self._wifi_manager.networks)
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _on_network_updated(self, networks: list[Network]):
self._networks = {network.ssid: network for network in networks}
self._update_buttons()
@@ -326,8 +318,6 @@ class WifiUIMici(NavWidget):
if isinstance(btn, WifiButton) and btn.network.ssid not in self._networks:
btn.set_network_missing(True)
self._move_network_to_front(self._wifi_manager.wifi_state.ssid)
def _connect_with_password(self, ssid: str, password: str):
self._wifi_manager.connect_to_network(ssid, password)
self._move_network_to_front(ssid, scroll=True)
@@ -382,6 +372,8 @@ class WifiUIMici(NavWidget):
def _update_state(self):
super()._update_state()
self._move_network_to_front(self._wifi_manager.wifi_state.ssid)
# Show loading animation near end
max_scroll = max(self._scroller.content_size - self._scroller.rect.width, 1)
progress = -self._scroller.scroll_panel.get_offset() / max_scroll
@@ -389,7 +381,7 @@ class WifiUIMici(NavWidget):
self._loading_animation.show_event()
def _render(self, _):
self._scroller.render(self._rect)
super()._render(self._rect)
anim_w = 90
anim_x = self._rect.x + self._rect.width - anim_w

View File

@@ -1,7 +1,5 @@
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici
@@ -9,7 +7,6 @@ from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets.nav_widget import NavWidget
class SettingsBigButton(BigButton):
@@ -17,7 +14,7 @@ class SettingsBigButton(BigButton):
return 64
class SettingsLayout(NavWidget):
class SettingsLayout(NavScroller):
def __init__(self):
super().__init__()
self._params = Params()
@@ -42,7 +39,7 @@ class SettingsLayout(NavWidget):
firehose_btn = SettingsBigButton("firehose", "", "icons_mici/settings/firehose.png", icon_size=(52, 62))
firehose_btn.set_click_callback(lambda: gui_app.push_widget(firehose_panel))
self._scroller = Scroller([
self._scroller.add_widgets([
toggles_btn,
network_btn,
device_btn,
@@ -56,14 +53,3 @@ class SettingsLayout(NavWidget):
self.set_back_callback(gui_app.pop_widget)
self._font_medium = gui_app.font(FontWeight.MEDIUM)
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)

View File

@@ -1,17 +1,15 @@
import pyray as rl
from cereal import log
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
from openpilot.selfdrive.ui.ui_state import ui_state
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
class TogglesLayoutMici(NavWidget):
class TogglesLayoutMici(NavScroller):
def __init__(self):
super().__init__()
self.set_back_callback(gui_app.pop_widget)
@@ -25,7 +23,7 @@ class TogglesLayoutMici(NavWidget):
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
self._scroller = Scroller([
self._scroller.add_widgets([
self._personality_toggle,
self._experimental_btn,
is_metric_toggle,
@@ -68,13 +66,8 @@ class TogglesLayoutMici(NavWidget):
def show_event(self):
super().show_event()
self._scroller.show_event()
self._update_toggles()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _update_toggles(self):
ui_state.update_params()
@@ -93,6 +86,3 @@ class TogglesLayoutMici(NavWidget):
# Refresh toggles from params to mirror external changes
for key, item in self._refresh_toggles:
item.set_checked(ui_state.params.get_bool(key))
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)

View File

@@ -17,8 +17,6 @@ except ImportError:
SCROLLING_SPEED_PX_S = 50
COMPLICATION_SIZE = 36
LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
LABEL_HORIZONTAL_PADDING = 40
LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma
COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
@@ -103,6 +101,9 @@ class BigCircleToggle(BigCircleButton):
class BigButton(Widget):
LABEL_HORIZONTAL_PADDING = 40
LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", icon_size: tuple[int, int] = (64, 64),
@@ -145,7 +146,7 @@ class BigButton(Widget):
def _width_hint(self) -> int:
# Single line if scrolling, so hide behind icon if exists
icon_size = self._icon_size[0] if self._txt_icon and self._scroll and self.value else 0
return int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - icon_size)
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size)
def _get_label_font_size(self):
if len(self.text) <= 18:
@@ -193,20 +194,33 @@ class BigButton(Widget):
def set_position(self, x: float, y: float) -> None:
super().set_position(x + self._shake_offset, y)
def _handle_background(self) -> tuple[rl.Texture, float, float, float]:
# draw _txt_default_bg
txt_bg = self._txt_default_bg
if not self.enabled:
txt_bg = self._txt_disabled_bg
elif self.is_pressed:
txt_bg = self._txt_pressed_bg
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
return txt_bg, btn_x, btn_y, scale
def _draw_content(self, btn_y: float):
# LABEL ------------------------------------------------------------------
label_x = self._rect.x + LABEL_HORIZONTAL_PADDING
label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
self._label.set_color(label_color)
label_rect = rl.Rectangle(label_x, btn_y + LABEL_VERTICAL_PADDING, self._width_hint(),
self._rect.height - LABEL_VERTICAL_PADDING * 2)
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
if self.value:
label_y = btn_y + self._rect.height - LABEL_VERTICAL_PADDING
sub_label_height = self._sub_label.get_content_height(self._width_hint())
sub_label_rect = rl.Rectangle(label_x, label_y - sub_label_height, self._width_hint(), sub_label_height)
label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint())
sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y
sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height)
self._sub_label.render(sub_label_rect)
# ICON -------------------------------------------------------------------
@@ -224,16 +238,7 @@ class BigButton(Widget):
rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.Color(255, 255, 255, int(255 * 0.9)))
def _render(self, _):
# draw _txt_default_bg
txt_bg = self._txt_default_bg
if not self.enabled:
txt_bg = self._txt_disabled_bg
elif self.is_pressed:
txt_bg = self._txt_pressed_bg
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
txt_bg, btn_x, btn_y, scale = self._handle_background()
if self._scroll:
# draw black background since images are transparent
@@ -293,7 +298,7 @@ class BigMultiToggle(BigToggle):
self.set_value(self._options[0])
def _width_hint(self) -> int:
return int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)

View File

@@ -96,8 +96,6 @@ class SunnylinkConsentPage(Widget):
self._primary_btn.set_text(step_data["primary_btn"])
self._primary_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
return -1
class SunnylinkOnboarding:
def __init__(self):

View File

@@ -147,5 +147,3 @@ class TripsLayout(Widget):
y = self._render_stat_group(x, y, w, card_height, tr("ALL TIME"), all_time, is_metric)
y += spacing
y = self._render_stat_group(x, y, w, card_height, tr("PAST WEEK"), week, is_metric)
return -1

View File

@@ -10,11 +10,10 @@ from cereal import custom
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.nav_widget import NavWidget, Widget
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
class ModelsLayoutMici(NavWidget):
class ModelsLayoutMici(NavScroller):
def __init__(self, back_callback: Callable):
super().__init__()
self.set_back_callback(back_callback)
@@ -27,8 +26,8 @@ class ModelsLayoutMici(NavWidget):
self.cancel_download_btn = BigButton(tr("cancel download"), "", "")
self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadIndex"))
self.main_items: list[Widget] = [self.current_model_btn, self.cancel_download_btn]
self._scroller = Scroller(self.main_items, snap_items=False)
self.main_items = [self.current_model_btn, self.cancel_download_btn]
self._scroller.add_widgets(self.main_items)
@property
def model_manager(self):
@@ -42,7 +41,7 @@ class ModelsLayoutMici(NavWidget):
folders.setdefault(folder, []).append(bundle)
return folders
def _show_selection_view(self, items: list[Widget], back_callback: Callable):
def _show_selection_view(self, items, back_callback: Callable):
self._scroller._items = items
for item in items:
item.set_touch_valid_callback(lambda: self._scroller.scroll_panel.is_touch_valid() and self._scroller.enabled)
@@ -113,10 +112,3 @@ class ModelsLayoutMici(NavWidget):
self.cancel_download_btn.set_visible(False)
self.current_model_btn.set_enabled(ui_state.is_offroad())
self.current_model_btn.set_text(tr("current model"))
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
super().show_event()
self._scroller.show_event()

View File

@@ -28,10 +28,6 @@ class SunnylinkConsentPage(SetupTermsPage):
def _content_height(self):
return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset()
def _render(self, _):
super()._render(_)
return -1
def _render_content(self, scroll_offset):
self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset)
self._title_header.render()

View File

@@ -6,7 +6,6 @@ See the LICENSE.md file in the root directory for more details.
"""
from collections.abc import Callable
import pyray as rl
from cereal import custom
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2
@@ -16,12 +15,11 @@ from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.system.ui.lib.application import gui_app, MousePos
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined
class SunnylinkLayoutMici(NavWidget):
class SunnylinkLayoutMici(NavScroller):
def __init__(self, back_callback: Callable):
super().__init__()
self.set_back_callback(back_callback)
@@ -41,14 +39,14 @@ class SunnylinkLayoutMici(NavWidget):
self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False,
toggle_callback=self._sunnylink_uploader_callback)
self._scroller = Scroller([
self._scroller.add_widgets([
self._sunnylink_toggle,
self._sunnylink_sponsor_button,
self._sunnylink_pair_button,
self._backup_btn,
self._restore_btn,
self._sunnylink_uploader_toggle
], snap_items=False)
])
def _update_state(self):
super()._update_state()
@@ -76,12 +74,8 @@ class SunnylinkLayoutMici(NavWidget):
def show_event(self):
super().show_event()
self._scroller.show_event()
ui_state.update_params()
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)
@staticmethod
def _sunnylink_toggle_callback(state: bool):
sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version

View File

@@ -20,7 +20,6 @@ class HudRendererSP(HudRenderer):
self.blind_spot_indicators.update()
def _render(self, rect: rl.Rectangle) -> None:
super()._render(rect)
self.blind_spot_indicators.render(rect)
def _has_blind_spot_detected(self) -> bool:

View File

@@ -456,14 +456,10 @@ class Tici(HardwareBase):
def configure_modem(self):
sim_id = self.get_sim_info().get('sim_id', '')
modem = self.get_modem()
try:
manufacturer = str(modem.Get(MM_MODEM, 'Manufacturer', dbus_interface=DBUS_PROPS, timeout=TIMEOUT))
except Exception:
manufacturer = None
cmds = []
modem = self.get_modem()
# Quectel EG25
if self.get_device_type() in ("tizi", ):
# clear out old blue prime initial APN
os.system('mmcli -m any --3gpp-set-initial-eps-bearer-settings="apn="')
@@ -478,16 +474,8 @@ class Tici(HardwareBase):
'AT+QNVFW="/nv/item_files/ims/IMS_enable",00',
'AT+QNVFW="/nv/item_files/modem/mmode/ue_usage_setting",01',
]
elif manufacturer == 'Cavli Inc.':
cmds += [
'AT^SIMSWAP=1', # use SIM slot, instead of internal eSIM
'AT$QCSIMSLEEP=0', # disable SIM sleep
'AT$QCSIMCFG=SimPowerSave,0', # more sleep disable
# ethernet config
'AT$QCPCFG=usbNet,0',
'AT$QCNETDEVCTL=3,1',
]
# Quectel EG916
else:
# this modem gets upset with too many AT commands
if sim_id is None or len(sim_id) == 0:

View File

@@ -23,9 +23,12 @@ class NMDeviceStateReason(IntEnum):
# https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceStateReason
NONE = 0
UNKNOWN = 1
IP_CONFIG_UNAVAILABLE = 5
NO_SECRETS = 7
SUPPLICANT_DISCONNECT = 8
SUPPLICANT_TIMEOUT = 11
CONNECTION_REMOVED = 38
USER_REQUESTED = 39
SSID_NOT_FOUND = 53
NEW_ACTIVATION = 60

View File

@@ -0,0 +1,906 @@
"""Tests for WifiManager._handle_state_change.
Tests the state machine in isolation by constructing a WifiManager with mocked
DBus, then calling _handle_state_change directly with NM state transitions.
"""
import pytest
from jeepney.low_level import MessageType
from pytest_mock import MockerFixture
from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason
from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus
def _make_wm(mocker: MockerFixture, connections=None):
"""Create a WifiManager with only the fields _handle_state_change touches."""
mocker.patch.object(WifiManager, '_initialize')
wm = WifiManager.__new__(WifiManager)
wm._exit = True # prevent stop() from doing anything in __del__
wm._conn_monitor = mocker.MagicMock()
wm._connections = dict(connections or {})
wm._wifi_state = WifiState()
wm._user_epoch = 0
wm._callback_queue = []
wm._need_auth = []
wm._activated = []
wm._update_networks = mocker.MagicMock()
wm._update_active_connection_info = mocker.MagicMock()
wm._get_active_wifi_connection = mocker.MagicMock(return_value=(None, None))
return wm
def fire(wm: WifiManager, new_state: int, prev_state: int = NMDeviceState.UNKNOWN,
reason: int = NMDeviceStateReason.NONE) -> None:
"""Feed a state change into the handler."""
wm._handle_state_change(new_state, prev_state, reason)
def fire_wpa_connect(wm: WifiManager) -> None:
"""WPA handshake then IP negotiation through ACTIVATED, as seen on device."""
fire(wm, NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.IP_CONFIG)
fire(wm, NMDeviceState.IP_CHECK)
fire(wm, NMDeviceState.SECONDARIES)
fire(wm, NMDeviceState.ACTIVATED)
# ---------------------------------------------------------------------------
# Basic transitions
# ---------------------------------------------------------------------------
class TestDisconnected:
def test_generic_disconnect_clears_state(self, mocker):
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.UNKNOWN)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
wm._update_networks.assert_not_called()
def test_new_activation_is_noop(self, mocker):
"""NEW_ACTIVATION means NM is about to connect to another network — don't clear."""
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="OldNet", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NEW_ACTIVATION)
assert wm._wifi_state.ssid == "OldNet"
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_connection_removed_keeps_other_connecting(self, mocker):
"""Forget A while connecting to B: CONNECTION_REMOVED for A must not clear B."""
wm = _make_wm(mocker, connections={"B": "/path/B"})
wm._set_connecting("B")
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_connection_removed_clears_when_forgotten(self, mocker):
"""Forget A: A is no longer in _connections, so state should clear."""
wm = _make_wm(mocker, connections={})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
class TestDeactivating:
def test_deactivating_noop_for_non_connection_removed(self, mocker):
"""DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op."""
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
assert wm._wifi_state.ssid == "Net"
assert wm._wifi_state.status == ConnectStatus.CONNECTED
@pytest.mark.parametrize("status, expected_clears", [
(ConnectStatus.CONNECTED, True),
(ConnectStatus.CONNECTING, False),
])
def test_deactivating_connection_removed(self, mocker, status, expected_clears):
"""DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING.
CONNECTED: forgetting the current network. The forgotten callback fires between
DEACTIVATING and DISCONNECTED — must clear here so the UI doesn't flash "connected"
after the eager _network_forgetting flag resets.
CONNECTING: forget A while connecting to B. DEACTIVATING fires for A's removal,
but B's CONNECTING state must be preserved.
"""
wm = _make_wm(mocker, connections={"B": "/path/B"})
wm._wifi_state = WifiState(ssid="B" if status == ConnectStatus.CONNECTING else "A", status=status)
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.CONNECTION_REMOVED)
if expected_clears:
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
else:
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
class TestPrepareConfig:
def test_user_initiated_skips_dbus_lookup(self, mocker):
"""User called _set_connecting('B') — PREPARE must not overwrite via DBus.
Reproduced on device: rapidly tap A then B. PREPARE's DBus lookup returns A's
stale conn_path, overwriting ssid to A for 1-2 frames. UI shows the "connecting"
indicator briefly jump to the wrong network row then back.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._set_connecting("B")
wm._get_active_wifi_connection.return_value = ("/path/A", {})
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
wm._get_active_wifi_connection.assert_not_called()
@pytest.mark.parametrize("state", [NMDeviceState.PREPARE, NMDeviceState.CONFIG])
def test_auto_connect_looks_up_ssid(self, mocker, state):
"""Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM."""
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
fire(wm, state)
assert wm._wifi_state.ssid == "AutoNet"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_auto_connect_dbus_fails(self, mocker):
"""Auto-connection but DBus returns None: ssid stays None, status CONNECTING."""
wm = _make_wm(mocker)
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_auto_connect_conn_path_not_in_connections(self, mocker):
"""DBus returns a conn_path that doesn't match any known connection."""
wm = _make_wm(mocker, connections={"Other": "/path/other"})
wm._get_active_wifi_connection.return_value = ("/path/unknown", {})
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.CONNECTING
class TestNeedAuth:
def test_wrong_password_fires_callback(self, mocker):
"""NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password."""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("SecNet")
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
wm.process_callbacks()
cb.assert_called_once_with("SecNet")
def test_failed_no_secrets_fires_callback(self, mocker):
"""FAILED+NO_SECRETS = wrong password (weak/gone network).
Confirmed on device: also fires when a hotspot turns off during connection.
NM can't complete the WPA handshake (AP vanished) and reports NO_SECRETS
rather than SSID_NOT_FOUND. The need_auth callback fires, so the UI shows
"wrong password" — a false positive, but same signal path.
Real device sequence (new connection, hotspot turned off immediately):
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
"""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("WeakNet")
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
wm.process_callbacks()
cb.assert_called_once_with("WeakNet")
def test_need_auth_then_failed_no_double_fire(self, mocker):
"""Real device sends NEED_AUTH(SUPPLICANT_DISCONNECT) then FAILED(NO_SECRETS) back-to-back.
The first clears ssid, so the second must not fire a duplicate callback.
Real device sequence: NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) → FAILED(NEED_AUTH, NO_SECRETS)
"""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("BadPass")
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert len(wm._callback_queue) == 1
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
reason=NMDeviceStateReason.NO_SECRETS)
assert len(wm._callback_queue) == 1 # no duplicate
wm.process_callbacks()
cb.assert_called_once_with("BadPass")
def test_no_ssid_no_callback(self, mocker):
"""If ssid is None when NEED_AUTH fires, no callback enqueued."""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
fire(wm, NMDeviceState.NEED_AUTH, reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert len(wm._callback_queue) == 0
def test_interrupted_auth_ignored(self, mocker):
"""Switching A->B: NEED_AUTH from A (prev=DISCONNECTED) must not fire callback.
Reproduced on device: rapidly switching between two saved networks can trigger a
rare false "wrong password" dialog for the previous network, even though both have
correct passwords. The stale NEED_AUTH has prev_state=DISCONNECTED (not CONFIG).
"""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("A")
wm._set_connecting("B")
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
assert len(wm._callback_queue) == 0
class TestPassthroughStates:
"""NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops."""
@pytest.mark.parametrize("state", [
NMDeviceState.NEED_AUTH,
NMDeviceState.IP_CONFIG,
NMDeviceState.IP_CHECK,
NMDeviceState.SECONDARIES,
NMDeviceState.FAILED,
])
def test_passthrough_is_noop(self, mocker, state):
wm = _make_wm(mocker)
wm._set_connecting("Net")
fire(wm, state, reason=NMDeviceStateReason.NONE)
assert wm._wifi_state.ssid == "Net"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
assert len(wm._callback_queue) == 0
class TestActivated:
def test_sets_connected(self, mocker):
"""ACTIVATED sets status to CONNECTED and fires callback."""
wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"})
cb = mocker.MagicMock()
wm.add_callbacks(activated=cb)
wm._set_connecting("MyNet")
wm._get_active_wifi_connection.return_value = ("/path/mynet", {})
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "MyNet"
assert len(wm._callback_queue) == 1
wm.process_callbacks()
cb.assert_called_once()
def test_conn_path_none_still_connected(self, mocker):
"""ACTIVATED but DBus returns None: status CONNECTED, ssid unchanged."""
wm = _make_wm(mocker)
wm._set_connecting("MyNet")
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "MyNet"
def test_activated_side_effects(self, mocker):
"""ACTIVATED persists the volatile connection to disk and updates active connection info."""
wm = _make_wm(mocker, connections={"Net": "/path/net"})
wm._set_connecting("Net")
wm._get_active_wifi_connection.return_value = ("/path/net", {})
fire(wm, NMDeviceState.ACTIVATED)
wm._conn_monitor.send_and_get_reply.assert_called_once()
wm._update_active_connection_info.assert_called_once()
wm._update_networks.assert_not_called()
# ---------------------------------------------------------------------------
# Thread races: _set_connecting on main thread vs _handle_state_change on monitor thread.
# Uses side_effect on the DBus mock to simulate _set_connecting running mid-handler.
# The epoch counter detects that a user action occurred during the slow DBus call
# and discards the stale update.
# ---------------------------------------------------------------------------
# The deterministic fixes (skip DBus lookup when ssid already set, prev_state guard
# on NEED_AUTH, DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED
# guard) shrink these race windows significantly. The epoch counter closes the
# remaining gaps.
class TestThreadRaces:
def test_prepare_race_user_tap_during_dbus(self, mocker):
"""User taps B while PREPARE's DBus call is in flight for auto-connect.
Monitor thread reads wifi_state (ssid=None), starts DBus call.
Main thread: _set_connecting("B"). Monitor thread writes back stale ssid from DBus.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
def user_taps_b_during_dbus(*args, **kwargs):
wm._set_connecting("B")
return ("/path/A", {})
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_activated_race_user_tap_during_dbus(self, mocker):
"""User taps B right as A finishes connecting (ACTIVATED handler running).
Monitor thread reads wifi_state (A, CONNECTING), starts DBus call.
Main thread: _set_connecting("B"). Monitor thread writes (A, CONNECTED), losing B.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._set_connecting("A")
def user_taps_b_during_dbus(*args, **kwargs):
wm._set_connecting("B")
return ("/path/A", {})
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_init_wifi_state_race_user_tap_during_dbus(self, mocker):
"""User taps B while _init_wifi_state's DBus calls are in flight.
_init_wifi_state runs from set_active(True) or worker error paths. It does
2 DBus calls (device State property + _get_active_wifi_connection) then
unconditionally writes _wifi_state. If the user taps a network during those
calls, _set_connecting("B") is overwritten with stale NM ground truth.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._wifi_device = "/dev/wifi0"
wm._router_main = mocker.MagicMock()
state_reply = mocker.MagicMock()
state_reply.body = [('u', NMDeviceState.ACTIVATED)]
wm._router_main.send_and_get_reply.return_value = state_reply
def user_taps_b_during_dbus(*args, **kwargs):
wm._set_connecting("B")
return ("/path/A", {})
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
wm._init_wifi_state()
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# ---------------------------------------------------------------------------
# Full sequences (NM signal order from real devices)
# ---------------------------------------------------------------------------
class TestFullSequences:
def test_normal_connect(self, mocker):
"""User connects to saved network: full happy path.
Real device sequence (switching from another connected network):
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
"""
wm = _make_wm(mocker, connections={"Home": "/path/home"})
wm._get_active_wifi_connection.return_value = ("/path/home", {})
wm._set_connecting("Home")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.IP_CONFIG)
fire(wm, NMDeviceState.IP_CHECK)
fire(wm, NMDeviceState.SECONDARIES)
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "Home"
def test_wrong_password_then_retry(self, mocker):
"""Wrong password → NEED_AUTH → FAILED → NM auto-reconnects to saved network.
Confirmed on device: wrong password for Shane's iPhone, NM auto-connected to unifi.
Real device sequence (switching from a connected network):
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) ← WPA handshake
→ PREPARE(NEED_AUTH, NONE) → CONFIG
→ NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) ← wrong password
→ FAILED(NEED_AUTH, NO_SECRETS) ← NM gives up
→ DISCONNECTED(FAILED, NONE)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED ← auto-reconnect to other saved network
"""
wm = _make_wm(mocker, connections={"Sec": "/path/sec"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("Sec")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
# FAILED(NO_SECRETS) follows but ssid is already cleared — no double-fire
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
assert len(wm._callback_queue) == 1
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
# Retry
wm._callback_queue.clear()
wm._set_connecting("Sec")
wm._get_active_wifi_connection.return_value = ("/path/sec", {})
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_switch_saved_networks(self, mocker):
"""Switch from A to B (both saved): NM signal sequence from real device.
Real device sequence:
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._get_active_wifi_connection.return_value = ("/path/B", {})
wm._set_connecting("B")
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.NEW_ACTIVATION)
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.NEW_ACTIVATION)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "B"
def test_rapid_switch_no_false_wrong_password(self, mocker):
"""Switch A→B quickly: A's interrupted NEED_AUTH must NOT show wrong password.
NOTE: The late NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) is common when rapidly
switching between networks with wrong/new passwords. Less common when switching between
saved networks with correct passwords. Not guaranteed — some switches skip it and go
straight from DISCONNECTED to PREPARE. The prev_state is consistently DISCONNECTED
for stale signals, so the prev_state guard reliably distinguishes them.
Worst-case signal sequence this protects against:
DEACTIVATING(NEW_ACTIVATION) → DISCONNECTED(NEW_ACTIVATION)
→ NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) ← A's stale auth failure
→ PREPARE → CONFIG → ... → ACTIVATED ← B connects
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._get_active_wifi_connection.return_value = ("/path/B", {})
wm._set_connecting("B")
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.NEW_ACTIVATION)
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.NEW_ACTIVATION)
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
assert len(wm._callback_queue) == 0
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_forget_while_connecting(self, mocker):
"""Forget the network we're currently connecting to (not yet ACTIVATED).
Confirmed on device: connected to unifi, tapped Shane's iPhone, then forgot
Shane's iPhone while at CONFIG. NM auto-connected to unifi afterward.
Real device sequence (switching then forgetting mid-connection):
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ DEACTIVATING(CONFIG, CONNECTION_REMOVED) ← forget at CONFIG
→ DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
→ PREPARE → CONFIG → ... → ACTIVATED ← NM auto-connects to other saved network
Note: DEACTIVATING fires from CONFIG (not ACTIVATED). wifi_state.status is
CONNECTING, so the DEACTIVATING handler is a no-op. DISCONNECTED clears state
(ssid removed from _connections by ConnectionRemoved), then PREPARE recovers
via DBus lookup for the auto-connect.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "Other": "/path/other"})
wm._get_active_wifi_connection.return_value = ("/path/other", {})
wm._set_connecting("A")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# User forgets A: ConnectionRemoved processed first, then state changes
del wm._connections["A"]
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTING # DEACTIVATING preserves CONNECTING
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# NM auto-connects to another saved network
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "Other"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "Other"
def test_forget_connected_network(self, mocker):
"""Forget the currently connected network (not switching to another).
Real device sequence:
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
ConnectionRemoved signal may or may not have been processed before state changes.
Either way, state must clear — we're forgetting what we're connected to, not switching.
"""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# DISCONNECTED follows — harmless since state is already cleared
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
def test_forget_A_connect_B(self, mocker):
"""Forget A while connecting to B: full signal sequence.
Real device sequence:
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
Signal order:
1. User: _set_connecting("B"), forget("A") removes A from _connections
2. NewConnection for B arrives → _connections["B"] = ...
3. DEACTIVATING(CONNECTION_REMOVED) — no-op
4. DISCONNECTED(CONNECTION_REMOVED) — B is in _connections, must not clear
5. PREPARE → CONFIG → NEED_AUTH → PREPARE → CONFIG → ... → ACTIVATED
"""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._set_connecting("B")
del wm._connections["A"]
wm._connections["B"] = "/path/B"
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
wm._get_active_wifi_connection.return_value = ("/path/B", {})
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "B"
def test_forget_A_connect_B_late_new_connection(self, mocker):
"""Forget A, connect B: NewConnection for B arrives AFTER DISCONNECTED.
This is the worst-case race: B isn't in _connections when DISCONNECTED fires,
so the guard can't protect it and state clears. PREPARE must recover by doing
the DBus lookup (ssid is None at that point).
Signal order:
1. User: _set_connecting("B"), forget("A") removes A from _connections
2. DEACTIVATING(CONNECTION_REMOVED) — B NOT in _connections, should be no-op
3. DISCONNECTED(CONNECTION_REMOVED) — B STILL NOT in _connections, clears state
4. NewConnection for B arrives late → _connections["B"] = ...
5. PREPARE (ssid=None, so DBus lookup recovers) → CONFIG → ACTIVATED
"""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._set_connecting("B")
del wm._connections["A"]
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
# B not in _connections yet, so state clears — this is the known edge case
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# NewConnection arrives late
wm._connections["B"] = "/path/B"
wm._get_active_wifi_connection.return_value = ("/path/B", {})
# PREPARE recovers: ssid is None so it looks up from DBus
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "B"
def test_auto_connect(self, mocker):
"""NM auto-connects (no user action, ssid starts None)."""
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "AutoNet"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "AutoNet"
def test_network_lost_during_connection(self, mocker):
"""Hotspot turned off while connecting (before ACTIVATED).
Confirmed on device: started new connection to Shane's iPhone, immediately
turned off the hotspot. NM can't complete WPA handshake and reports
FAILED(NO_SECRETS) — same signal as wrong password (false positive).
Real device sequence:
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
Note: no DEACTIVATING, no SUPPLICANT_DISCONNECT. The NEED_AUTH(CONFIG, NONE) is the
normal WPA handshake (not an error). NM gives up with NO_SECRETS because the AP
vanished mid-handshake.
"""
wm = _make_wm(mocker, connections={"Hotspot": "/path/hs"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("Hotspot")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# Second NEED_AUTH(CONFIG, NONE) — NM retries handshake, AP vanishing
fire(wm, NMDeviceState.NEED_AUTH)
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# NM gives up — reports NO_SECRETS (same as wrong password)
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
reason=NMDeviceStateReason.NO_SECRETS)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
wm.process_callbacks()
cb.assert_called_once_with("Hotspot")
@pytest.mark.xfail(reason="TODO: FAILED(SSID_NOT_FOUND) should emit error for UI")
def test_ssid_not_found(self, mocker):
"""Network drops off while connected — hotspot turned off.
NM docs: SSID_NOT_FOUND (53) = "The WiFi network could not be found"
Confirmed on device: connected to Shane's iPhone, then turned off the hotspot.
No DEACTIVATING fires — NM goes straight from ACTIVATED to FAILED(SSID_NOT_FOUND).
NM retries connecting (PREPARE → CONFIG → ... → FAILED(CONFIG, SSID_NOT_FOUND))
before finally giving up with DISCONNECTED.
NOTE: turning off a hotspot during initial connection (before ACTIVATED) typically
produces FAILED(NO_SECRETS) instead of SSID_NOT_FOUND (see test_failed_no_secrets).
Real device sequence (hotspot turned off while connected):
FAILED(ACTIVATED, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ FAILED(CONFIG, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
The UI error callback mechanism is intentionally deferred — for now just clear state.
"""
wm = _make_wm(mocker, connections={"GoneNet": "/path/gone"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("GoneNet")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.SSID_NOT_FOUND)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert wm._wifi_state.ssid is None
def test_failed_then_disconnected_clears_state(self, mocker):
"""After FAILED, NM always transitions to DISCONNECTED to clean up.
NM docs: FAILED (120) = "failed to connect, cleaning up the connection request"
Full sequence: ... → FAILED(reason) → DISCONNECTED(NONE)
"""
wm = _make_wm(mocker)
wm._set_connecting("Net")
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NONE)
assert wm._wifi_state.status == ConnectStatus.CONNECTING # FAILED(NONE) is a no-op
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NONE)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
def test_user_requested_disconnect(self, mocker):
"""User explicitly disconnects from the network.
NM docs: USER_REQUESTED (39) = "Device disconnected by user or client"
Expected sequence: DEACTIVATING(USER_REQUESTED) → DISCONNECTED(USER_REQUESTED)
"""
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="MyNet", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.USER_REQUESTED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# ---------------------------------------------------------------------------
# Worker error recovery: DBus errors in activate/connect re-sync with NM
# ---------------------------------------------------------------------------
# Verified on device: when ActivateConnection returns UnknownConnection error,
# NM emits no state signals. The worker error path is the only recovery point.
class TestWorkerErrorRecovery:
"""Worker threads re-sync with NM via _init_wifi_state on DBus errors,
preserving actual NM state instead of blindly clearing to DISCONNECTED."""
def _mock_init_restores(self, wm, mocker, ssid, status):
"""Replace _init_wifi_state with a mock that simulates NM reporting the given state."""
mock = mocker.MagicMock(
side_effect=lambda: setattr(wm, '_wifi_state', WifiState(ssid=ssid, status=status))
)
wm._init_wifi_state = mock
return mock
def test_activate_dbus_error_resyncs(self, mocker):
"""ActivateConnection returns DBus error while A is connected.
NM rejects the request — no state signals emitted. Worker must re-read NM
state to discover A is still connected, not clear to DISCONNECTED.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._wifi_device = "/dev/wifi0"
wm._nm = mocker.MagicMock()
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._router_main = mocker.MagicMock()
error_reply = mocker.MagicMock()
error_reply.header.message_type = MessageType.error
wm._router_main.send_and_get_reply.return_value = error_reply
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
wm.activate_connection("B", block=True)
mock_init.assert_called_once()
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_connect_to_network_dbus_error_resyncs(self, mocker):
"""AddAndActivateConnection2 returns DBus error while A is connected."""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_device = "/dev/wifi0"
wm._nm = mocker.MagicMock()
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._router_main = mocker.MagicMock()
wm._forgotten = []
error_reply = mocker.MagicMock()
error_reply.header.message_type = MessageType.error
wm._router_main.send_and_get_reply.return_value = error_reply
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
# Run worker thread synchronously
workers = []
mocker.patch('openpilot.system.ui.lib.wifi_manager.threading.Thread',
side_effect=lambda target, **kw: type('T', (), {'start': lambda self: workers.append(target)})())
wm.connect_to_network("B", "password123")
workers[-1]()
mock_init.assert_called_once()
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTED

View File

@@ -145,10 +145,9 @@ class ConnectStatus(IntEnum):
CONNECTED = 2
@dataclass
@dataclass(frozen=True)
class WifiState:
ssid: str | None = None
prev_ssid: str | None = None
status: ConnectStatus = ConnectStatus.DISCONNECTED
@@ -176,6 +175,7 @@ class WifiManager:
# State
self._connections: dict[str, str] = {} # ssid -> connection path, updated via NM signals
self._wifi_state: WifiState = WifiState()
self._user_epoch: int = 0
self._ipv4_address: str = ""
self._current_network_metered: MeteredType = MeteredType.UNKNOWN
self._tethering_password: str = ""
@@ -197,7 +197,7 @@ class WifiManager:
self._networks_updated: list[Callable[[list[Network]], None]] = []
self._disconnected: list[Callable[[], None]] = []
self._lock = threading.Lock()
self._scan_lock = threading.Lock()
self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True)
self._state_thread = threading.Thread(target=self._monitor_state, daemon=True)
self._initialize()
@@ -227,19 +227,27 @@ class WifiManager:
cloudlog.warning("No WiFi device found")
return
epoch = self._user_epoch
dev_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_DEVICE_IFACE)
dev_state = self._router_main.send_and_get_reply(Properties(dev_addr).get('State')).body[0][1]
wifi_state = WifiState()
ssid: str | None = None
status = ConnectStatus.DISCONNECTED
if NMDeviceState.PREPARE <= dev_state <= NMDeviceState.SECONDARIES and dev_state != NMDeviceState.NEED_AUTH:
wifi_state.status = ConnectStatus.CONNECTING
status = ConnectStatus.CONNECTING
elif dev_state == NMDeviceState.ACTIVATED:
wifi_state.status = ConnectStatus.CONNECTED
status = ConnectStatus.CONNECTED
conn_path, _ = self._get_active_wifi_connection()
if conn_path:
wifi_state.ssid = next((s for s, p in self._connections.items() if p == conn_path), None)
self._wifi_state = wifi_state
ssid = next((s for s, p in self._connections.items() if p == conn_path), None)
# Discard if user acted during DBus calls
if self._user_epoch != epoch:
return
self._wifi_state = WifiState(ssid=ssid, status=status)
if block:
worker()
@@ -281,21 +289,22 @@ class WifiManager:
@property
def connecting_to_ssid(self) -> str | None:
return self._wifi_state.ssid if self._wifi_state.status == ConnectStatus.CONNECTING else None
wifi_state = self._wifi_state
return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTING else None
@property
def connected_ssid(self) -> str | None:
return self._wifi_state.ssid if self._wifi_state.status == ConnectStatus.CONNECTED else None
wifi_state = self._wifi_state
return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTED else None
@property
def tethering_password(self) -> str:
return self._tethering_password
def _set_connecting(self, ssid: str | None):
# Track prev ssid so late NEED_AUTH signals target the right network
self._wifi_state = WifiState(ssid=ssid,
prev_ssid=self.connecting_to_ssid if ssid is not None else None,
status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING)
# Called by user action, or sequentially from state change handler
self._user_epoch += 1
self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING)
def _enqueue_callbacks(self, cbs: list[Callable], *args):
for cb in cbs:
@@ -377,49 +386,71 @@ class WifiManager:
self._handle_state_change(new_state, previous_state, change_reason)
def _handle_state_change(self, new_state: int, _: int, change_reason: int):
# TODO: known race conditions when switching networks (e.g. forget A, connect to B):
# 1. DEACTIVATING/DISCONNECTED + CONNECTION_REMOVED: fires before NewConnection for B
# arrives, so _set_connecting(None) clears B's CONNECTING state causing UI flicker.
# DEACTIVATING(CONNECTION_REMOVED): wifi_state (B, CONNECTING) -> (None, DISCONNECTED)
# Fix: make DEACTIVATING a no-op, and guard DISCONNECTED with
# `if wifi_state.ssid not in _connections` (NewConnection arrives between the two).
# 2. PREPARE/CONFIG ssid lookup: DBus may return stale A's conn_path, overwriting B.
# PREPARE(0): wifi_state (B, CONNECTING) -> (A, CONNECTING)
# Fix: only do DBus lookup when wifi_state.ssid is None (auto-connections);
# user-initiated connections already have ssid set via _set_connecting.
def _handle_state_change(self, new_state: int, prev_state: int, change_reason: int):
# Thread safety: _wifi_state is read/written by both the monitor thread (this handler)
# and the main thread (_set_connecting via connect/activate). PREPARE/CONFIG and ACTIVATED
# have a read-then-write pattern with a slow DBus call in between — if _set_connecting
# runs mid-call, the handler would overwrite the user's newer state with stale data.
#
# The _user_epoch counter solves this without locks. _set_connecting increments the epoch
# on every user action. Handlers snapshot the epoch before their DBus call and compare
# after: if it changed, a user action occurred during the call and the stale result is
# discarded. Combined with deterministic fixes (skip DBus lookup when ssid already set,
# DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED guard),
# all known race windows are closed.
# TODO: Handle (FAILED, SSID_NOT_FOUND) and emit for ui to show error
# TODO: Handle (FAILED, SSID_NOT_FOUND) and emit for UI to show error
# Happens when network drops off after starting connection
if new_state == NMDeviceState.DISCONNECTED:
if change_reason != NMDeviceStateReason.NEW_ACTIVATION:
# catches CONNECTION_REMOVED reason when connection is forgotten
if change_reason == NMDeviceStateReason.NEW_ACTIVATION:
return
# Guard: forget A while connecting to B fires CONNECTION_REMOVED. Don't clear B's state
# if B is still a known connection. If B hasn't arrived in _connections yet (late
# NewConnection), state clears here but PREPARE recovers via DBus lookup.
if (change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.ssid and
self._wifi_state.ssid in self._connections):
return
self._set_connecting(None)
elif new_state in (NMDeviceState.PREPARE, NMDeviceState.CONFIG):
# Set connecting status when NetworkManager connects to known networks on its own
epoch = self._user_epoch
if self._wifi_state.ssid is not None:
self._wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING)
return
# Auto-connection when NetworkManager connects to known networks on its own (ssid=None): look up ssid from NM
wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING)
conn_path, _ = self._get_active_wifi_connection(self._conn_monitor)
# Discard if user acted during DBus call
if self._user_epoch != epoch:
return
if conn_path is None:
cloudlog.warning("Failed to get active wifi connection during PREPARE/CONFIG state")
else:
wifi_state.ssid = next((s for s, p in self._connections.items() if p == conn_path), None)
wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None))
self._wifi_state = wifi_state
# BAD PASSWORD - use prev if current has already moved on to a new connection
# BAD PASSWORD
# - strong network rejects with NEED_AUTH+SUPPLICANT_DISCONNECT
# - weak/gone network fails with FAILED+NO_SECRETS
elif ((new_state == NMDeviceState.NEED_AUTH and change_reason == NMDeviceStateReason.SUPPLICANT_DISCONNECT) or
# TODO: sometimes on PC it's observed no future signals are fired if mouse is held down blocking wrong password dialog
elif ((new_state == NMDeviceState.NEED_AUTH and change_reason == NMDeviceStateReason.SUPPLICANT_DISCONNECT
and prev_state == NMDeviceState.CONFIG) or
(new_state == NMDeviceState.FAILED and change_reason == NMDeviceStateReason.NO_SECRETS)):
failed_ssid = self._wifi_state.prev_ssid or self._wifi_state.ssid
if failed_ssid:
self._enqueue_callbacks(self._need_auth, failed_ssid)
self._wifi_state.prev_ssid = None
if self._wifi_state.ssid == failed_ssid:
# prev_state guard: real auth failures come from CONFIG (supplicant handshake).
# Stale NEED_AUTH from a prior connection during network switching arrives with
# prev_state=DISCONNECTED and must be ignored to avoid a false wrong-password callback.
if self._wifi_state.ssid:
self._enqueue_callbacks(self._need_auth, self._wifi_state.ssid)
self._set_connecting(None)
elif new_state in (NMDeviceState.NEED_AUTH, NMDeviceState.IP_CONFIG, NMDeviceState.IP_CHECK,
@@ -428,29 +459,37 @@ class WifiManager:
elif new_state == NMDeviceState.ACTIVATED:
# Note that IP address from Ip4Config may not be propagated immediately and could take until the next scan results
wifi_state = replace(self._wifi_state, prev_ssid=None, status=ConnectStatus.CONNECTED)
epoch = self._user_epoch
wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTED)
conn_path, _ = self._get_active_wifi_connection(self._conn_monitor)
# Discard if user acted during DBus call
if self._user_epoch != epoch:
return
if conn_path is None:
cloudlog.warning("Failed to get active wifi connection during ACTIVATED state")
self._wifi_state = wifi_state
self._enqueue_callbacks(self._activated)
self._update_networks()
else:
wifi_state.ssid = next((s for s, p in self._connections.items() if p == conn_path), None)
wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None))
self._wifi_state = wifi_state
self._enqueue_callbacks(self._activated)
self._update_networks()
self._update_active_connection_info()
# Persist volatile connections (created by AddAndActivateConnection2) to disk
if conn_path is not None:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
save_reply = self._conn_monitor.send_and_get_reply(new_method_call(conn_addr, 'Save'))
if save_reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to persist connection to disk: {save_reply}")
elif new_state == NMDeviceState.DEACTIVATING:
if change_reason == NMDeviceStateReason.CONNECTION_REMOVED:
# When connection is forgotten
# Must clear state when forgetting the currently connected network so the UI
# doesn't flash "connected" after the eager "forgetting..." state resets
# (the forgotten callback fires between DEACTIVATING and DISCONNECTED).
# Only clear CONNECTED — CONNECTING must be preserved for forget-A-connect-B.
if change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.status == ConnectStatus.CONNECTED:
self._set_connecting(None)
def _network_scanner(self):
@@ -621,7 +660,8 @@ class WifiManager:
# Persisted to disk on ACTIVATED via Save()
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
self._set_connecting(None)
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
return
reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'AddAndActivateConnection2', 'a{sa{sv}}ooa{sv}',
@@ -629,7 +669,8 @@ class WifiManager:
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to add and activate connection for {ssid}: {reply}")
self._set_connecting(None)
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
threading.Thread(target=worker, daemon=True).start()
@@ -642,7 +683,6 @@ class WifiManager:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete'))
self._update_networks()
self._enqueue_callbacks(self._forgotten, ssid)
if block:
@@ -657,7 +697,8 @@ class WifiManager:
conn_path = self._connections.get(ssid, None)
if conn_path is None or self._wifi_device is None:
cloudlog.warning(f"Failed to activate connection for {ssid}: conn_path={conn_path}, wifi_device={self._wifi_device}")
self._set_connecting(None)
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
return
reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo',
@@ -665,7 +706,8 @@ class WifiManager:
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to activate connection for {ssid}: {reply}")
self._set_connecting(None)
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
if block:
worker()
@@ -675,11 +717,19 @@ class WifiManager:
def _deactivate_connection(self, ssid: str):
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
specific_obj_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')).body[0][1]
reply = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject'))
if reply.header.message_type == MessageType.error:
continue # object gone (e.g. rapid connect/disconnect)
specific_obj_path = reply.body[0][1]
if specific_obj_path != "/":
ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE)
ap_ssid = bytes(self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')).body[0][1]).decode("utf-8", "replace")
ap_reply = self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid'))
if ap_reply.header.message_type == MessageType.error:
continue # AP gone (e.g. mode switch)
ap_ssid = bytes(ap_reply.body[0][1]).decode("utf-8", "replace")
if ap_ssid == ssid:
self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,)))
@@ -797,7 +847,7 @@ class WifiManager:
return
def worker():
with self._lock:
with self._scan_lock:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return

View File

@@ -38,18 +38,13 @@ class Reset(Widget):
self._reset_state = ResetState.NONE
self._cancel_button = SmallButton("cancel")
self._cancel_button.set_click_callback(self._cancel_callback)
self._cancel_button.set_click_callback(gui_app.request_close)
self._reboot_button = FullRoundedButton("reboot")
self._reboot_button.set_click_callback(self._do_reboot)
self._confirm_slider = SmallSlider("reset", self._confirm)
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _do_reboot(self):
if PC:
return
@@ -121,8 +116,6 @@ class Reset(Widget):
self._reboot_button.rect.width,
self._reboot_button.rect.height))
return self._render_status
def _confirm(self):
self.start_reset()

View File

@@ -69,8 +69,6 @@ class NavWidget(Widget, abc.ABC):
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._set_up = False
@property
def back_enabled(self) -> bool:
return self._back_enabled() if callable(self._back_enabled) else self._back_enabled
@@ -96,6 +94,7 @@ class NavWidget(Widget, abc.ABC):
self._pos_filter.update_alpha(0.04)
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
# TODO: remove vertical scrolling and then this hacky logic to check if scroller is at top
scroller_at_top = False
vertical_scroller = False
# TODO: -20? snapping in WiFi dialog can make offset not be positive at the top
@@ -138,19 +137,6 @@ class NavWidget(Widget, abc.ABC):
def _update_state(self):
super()._update_state()
# Disable self's scroller while swiping away
if not self._set_up:
self._set_up = True
if hasattr(self, '_scroller'):
# TODO: use touch_valid
original_enabled = self._scroller._enabled
self._scroller.set_enabled(lambda: self.enabled and not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
elif hasattr(self, '_scroll_panel'):
original_enabled = self._scroll_panel.enabled
self._scroll_panel.set_enabled(lambda: self.enabled and not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
if self._trigger_animate_in:
self._pos_filter.x = self._rect.height
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT

View File

@@ -7,6 +7,7 @@ from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
ITEM_SPACING = 20
LINE_COLOR = rl.GRAY
@@ -66,7 +67,8 @@ class ScrollIndicator(Widget):
rl.Color(255, 255, 255, int(255 * 0.45)))
class Scroller(Widget):
class _Scroller(Widget):
"""Should use wrapper below to reduce boilerplate"""
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING,
pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True):
super().__init__()
@@ -78,7 +80,7 @@ class Scroller(Widget):
self._reset_scroll_at_show = True
self._scrolling_to: tuple[float | None, bool] = (None, False) # target offset, block user scrolling
self._scrolling_to: tuple[float | None, bool] = (None, False) # target offset, block_interaction
self._scrolling_to_filter = FirstOrderFilter(0.0, SCROLL_RC, 1 / gui_app.target_fps)
self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps)
self._zoom_out_t: float = 0.0
@@ -109,14 +111,13 @@ class Scroller(Widget):
self._pending_lift: set[Widget] = set()
self._pending_move: set[Widget] = set()
for item in items:
self.add_widget(item)
self.add_widgets(items)
def set_reset_scroll_at_show(self, scroll: bool):
self._reset_scroll_at_show = scroll
def scroll_to(self, pos: float, smooth: bool = False, block: bool = False):
assert not block or smooth, "Instant scroll cannot be blocking"
def scroll_to(self, pos: float, smooth: bool = False, block_interaction: bool = False):
assert not block_interaction or smooth, "Instant scroll cannot block user interaction"
# already there
if abs(pos) < 1:
@@ -126,7 +127,7 @@ class Scroller(Widget):
scroll_offset = self.scroll_panel.get_offset() - pos
if smooth:
self._scrolling_to_filter.x = self.scroll_panel.get_offset()
self._scrolling_to = scroll_offset, block
self._scrolling_to = scroll_offset, block_interaction
else:
self.scroll_panel.set_offset(scroll_offset)
@@ -151,6 +152,10 @@ class Scroller(Widget):
and not self.moving_items and (original_touch_valid_callback() if
original_touch_valid_callback else True))
def add_widgets(self, items: list[Widget]) -> None:
for item in items:
self.add_widget(item)
def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None:
"""Set whether scrolling is enabled (does not affect widget enabled state)."""
self._scroll_enabled = enabled
@@ -167,7 +172,7 @@ class Scroller(Widget):
else:
self._zoom_filter.update(0.85)
# Cancel auto-scroll if user starts manually scrolling (unless blocking)
# Cancel auto-scroll if user starts manually scrolling (unless block_interaction)
if (self.scroll_panel.state in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL) and
self._scrolling_to[0] is not None and not self._scrolling_to[1]):
self._scrolling_to = None, False
@@ -411,3 +416,31 @@ class Scroller(Widget):
super().hide_event()
for item in self._items:
item.hide_event()
class Scroller(Widget):
"""Wrapper for _Scroller so that children do not need to call events or pass down enabled for nav stack."""
def __init__(self, **kwargs):
super().__init__()
self._scroller = _Scroller([], **kwargs)
# pass down enabled to child widget for nav stack
self._scroller.set_enabled(lambda: self.enabled)
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _render(self, _):
self._scroller.render(self._rect)
class NavScroller(NavWidget, Scroller):
"""Full screen Scroller that properly supports nav stack w/ animations"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# pass down enabled to child widget for nav stack + disable while swiping away NavWidget
self._scroller.set_enabled(lambda: self.enabled and not self._swiping_away)

View File

@@ -77,8 +77,11 @@ else:
qt_libs = base_libs
cabana_env = qt_env.Clone()
if arch == "Darwin":
cabana_env['CPPPATH'] += [f"{brew_prefix}/include"]
cabana_env['LIBPATH'] += [f"{brew_prefix}/lib"]
cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'zstd', 'curl', 'yuv', 'usb-1.0'] + qt_libs
cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'bz2', 'zstd', 'curl', 'yuv', 'usb-1.0'] + qt_libs
opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../opendbc/dbc").abspath)
cabana_env['CXXFLAGS'] += [opendbc_path]

View File

@@ -5,7 +5,7 @@
#include "catch2/catch.hpp"
#include "tools/cabana/dbc/dbcmanager.h"
const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.zst";
const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2";
TEST_CASE("DBCFile::generateDBC") {
QString fn = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, "tesla_can");

View File

@@ -35,6 +35,21 @@ function loge() {
fi
}
function retry() {
local attempts=$1
shift
for i in $(seq 1 "$attempts"); do
if "$@"; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo " Attempt $i/$attempts failed, retrying in 5s..."
sleep 5
fi
done
return 1
}
function op_run_command() {
CMD="$@"
@@ -62,7 +77,8 @@ function op_get_openpilot_dir() {
done
# Fallback to hardcoded directories if not found
for dir in "$HOME/openpilot" "/data/openpilot"; do
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
for dir in "${SCRIPT_DIR%/tools}" "$HOME/openpilot" "/data/openpilot"; do
if [[ -f "$dir/launch_openpilot.sh" ]]; then
OPENPILOT_ROOT="$dir"
return 0
@@ -229,7 +245,7 @@ function op_setup() {
echo "Getting git submodules..."
st="$(date +%s)"
if ! git submodule update --jobs 4 --init --recursive; then
if ! retry 3 git submodule update --jobs 4 --init --recursive; then
echo -e "[${RED}${NC}] Getting git submodules failed!"
loge "ERROR_GIT_SUBMODULES"
return 1
@@ -239,7 +255,7 @@ function op_setup() {
echo "Pulling git lfs files..."
st="$(date +%s)"
if ! git lfs pull; then
if ! retry 3 git lfs pull; then
echo -e "[${RED}${NC}] Pulling git lfs files failed!"
loge "ERROR_GIT_LFS"
return 1
@@ -260,6 +276,11 @@ function op_activate_venv() {
set +e
source $OPENPILOT_ROOT/.venv/bin/activate &> /dev/null || true
set -e
# persist venv on PATH across GitHub Actions steps
if [ -n "$GITHUB_PATH" ]; then
echo "$OPENPILOT_ROOT/.venv/bin" >> "$GITHUB_PATH"
fi
}
function op_venv() {
@@ -290,6 +311,19 @@ function op_ssh() {
op_run_command tools/scripts/ssh.py "$@"
}
function op_script() {
op_before_cmd
case $1 in
som-debug ) op_run_command panda/scripts/som_debug.sh "${@:2}" ;;
* )
echo -e "Unknown script '$1'. Available scripts:"
echo -e " ${BOLD}som-debug${NC} SOM serial debug console via panda"
return 1
;;
esac
}
function op_check() {
VERBOSE=1
op_before_cmd
@@ -420,6 +454,9 @@ function op_default() {
echo -e " ${BOLD}adb${NC} Run adb shell"
echo -e " ${BOLD}ssh${NC} comma prime SSH helper"
echo ""
echo -e "${BOLD}${UNDERLINE}Commands [Scripts]:${NC}"
echo -e " ${BOLD}script${NC} Run a script (e.g. op script som-debug)"
echo ""
echo -e "${BOLD}${UNDERLINE}Commands [Testing]:${NC}"
echo -e " ${BOLD}sim${NC} Run openpilot in a simulator"
echo -e " ${BOLD}lint${NC} Run the linter"
@@ -479,6 +516,7 @@ function _op() {
post-commit ) shift 1; op_install_post_commit "$@" ;;
adb ) shift 1; op_adb "$@" ;;
ssh ) shift 1; op_ssh "$@" ;;
script ) shift 1; op_script "$@" ;;
* ) op_default "$@" ;;
esac
}

View File

@@ -12,7 +12,7 @@ if arch != "Darwin":
replay_lib_src.append("qcom_decoder.cc")
replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks)
Export('replay_lib')
replay_libs = [replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'zstd', 'curl', 'yuv', 'ncurses'] + base_libs
replay_libs = [replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'bz2', 'zstd', 'curl', 'yuv', 'ncurses'] + base_libs
replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks)
if GetOption('extras'):

View File

@@ -9,7 +9,9 @@
bool LogReader::load(const std::string &url, std::atomic<bool> *abort, bool local_cache, int chunk_size, int retries) {
std::string data = FileReader(local_cache, chunk_size, retries).read(url, abort);
if (!data.empty()) {
if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) {
if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) {
data = decompressBZ2(data, abort);
} else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) {
data = decompressZST(data, abort);
}
}

View File

@@ -174,9 +174,9 @@ void Route::addFileToSegment(int n, const std::string &file) {
auto pos = name.find_last_of("--");
name = pos != std::string::npos ? name.substr(pos + 2) : name;
if (name == "rlog.zst" || name == "rlog") {
if (name == "rlog.bz2" || name == "rlog.zst" || name == "rlog") {
segments_[n].rlog = file;
} else if (name == "qlog.zst" || name == "qlog") {
} else if (name == "qlog.bz2" || name == "qlog.zst" || name == "qlog") {
segments_[n].qlog = file;
} else if (name == "fcamera.hevc") {
segments_[n].road_cam = file;

View File

@@ -2,14 +2,14 @@
#include "catch2/catch.hpp"
#include "tools/replay/replay.h"
const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.zst";
const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2";
TEST_CASE("LogReader") {
SECTION("corrupt log") {
FileReader reader(true);
std::string corrupt_content = reader.read(TEST_RLOG_URL);
corrupt_content.resize(corrupt_content.length() / 2);
corrupt_content = decompressZST(corrupt_content);
corrupt_content = decompressBZ2(corrupt_content);
LogReader log;
REQUIRE(log.load(corrupt_content.data(), corrupt_content.size()));
REQUIRE(log.events.size() > 0);

View File

@@ -1,5 +1,6 @@
#include "tools/replay/util.h"
#include <bzlib.h>
#include <curl/curl.h>
#include <openssl/sha.h>
@@ -279,6 +280,47 @@ bool httpDownload(const std::string &url, const std::string &file, size_t chunk_
return httpDownload(url, of, chunk_size, size, abort);
}
std::string decompressBZ2(const std::string &in, std::atomic<bool> *abort) {
return decompressBZ2((std::byte *)in.data(), in.size(), abort);
}
std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic<bool> *abort) {
if (in_size == 0) return {};
bz_stream strm = {};
int bzerror = BZ2_bzDecompressInit(&strm, 0, 0);
assert(bzerror == BZ_OK);
strm.next_in = (char *)in;
strm.avail_in = in_size;
std::string out(in_size * 5, '\0');
do {
strm.next_out = (char *)(&out[strm.total_out_lo32]);
strm.avail_out = out.size() - strm.total_out_lo32;
const char *prev_write_pos = strm.next_out;
bzerror = BZ2_bzDecompress(&strm);
if (bzerror == BZ_OK && prev_write_pos == strm.next_out) {
// content is corrupt
bzerror = BZ_STREAM_END;
rWarning("decompressBZ2 error: content is corrupt");
break;
}
if (bzerror == BZ_OK && strm.avail_in > 0 && strm.avail_out == 0) {
out.resize(out.size() * 2);
}
} while (bzerror == BZ_OK && !(abort && *abort));
BZ2_bzDecompressEnd(&strm);
if (bzerror == BZ_STREAM_END && !(abort && *abort)) {
out.resize(strm.total_out_lo32);
out.shrink_to_fit();
return out;
}
return {};
}
std::string decompressZST(const std::string &in, std::atomic<bool> *abort) {
return decompressZST((std::byte *)in.data(), in.size(), abort);
}

View File

@@ -48,6 +48,8 @@ private:
std::string sha256(const std::string &str);
void precise_nano_sleep(int64_t nanoseconds, std::atomic<bool> &interrupt_requested);
std::string decompressBZ2(const std::string &in, std::atomic<bool> *abort = nullptr);
std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic<bool> *abort = nullptr);
std::string decompressZST(const std::string &in, std::atomic<bool> *abort = nullptr);
std::string decompressZST(const std::byte *in, size_t in_size, std::atomic<bool> *abort = nullptr);
std::string getUrlWithoutQuery(const std::string &url);

7
uv.lock generated
View File

@@ -113,6 +113,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" },
]
[[package]]
name = "bzip2"
version = "1.0.8"
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=releases#11895d3f6bc62a229e84c87505948f15f9018ce0" }
[[package]]
name = "capnproto"
version = "1.0.1"
@@ -782,6 +787,7 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aiortc" },
{ name = "av" },
{ name = "bzip2" },
{ name = "capnproto" },
{ name = "casadi" },
{ name = "cffi" },
@@ -857,6 +863,7 @@ requires-dist = [
{ name = "aiohttp" },
{ name = "aiortc" },
{ name = "av" },
{ name = "bzip2", git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=releases" },
{ name = "capnproto", git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=releases" },
{ name = "casadi", specifier = ">=3.6.6" },
{ name = "cffi" },