diff --git a/.github/workflows/auto-cache/action.yaml b/.github/workflows/auto-cache/action.yaml deleted file mode 100644 index 42c8f8fd2..000000000 --- a/.github/workflows/auto-cache/action.yaml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/badges.yaml b/.github/workflows/badges.yaml index c0a1b933e..d170a9636 100644 --- a/.github/workflows/badges.yaml +++ b/.github/workflows/badges.yaml @@ -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 diff --git a/.github/workflows/cereal_validation.yaml b/.github/workflows/cereal_validation.yaml index 87cd800ee..3a864ebef 100644 --- a/.github/workflows/cereal_validation.yaml +++ b/.github/workflows/cereal_validation.yaml @@ -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 diff --git a/.github/workflows/compile-openpilot/action.yaml b/.github/workflows/compile-openpilot/action.yaml deleted file mode 100644 index 627b4845a..000000000 --- a/.github/workflows/compile-openpilot/action.yaml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/model_review.yaml b/.github/workflows/model_review.yaml index 6b8ce143d..2775dbc57 100644 --- a/.github/workflows/model_review.yaml +++ b/.github/workflows/model_review.yaml @@ -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<> $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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d3be31838..908c57039 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index 5b25c04f1..e7933dc47 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -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 diff --git a/.github/workflows/setup-with-retry/action.yaml b/.github/workflows/setup-with-retry/action.yaml deleted file mode 100644 index 923cc3aad..000000000 --- a/.github/workflows/setup-with-retry/action.yaml +++ /dev/null @@ -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 diff --git a/.github/workflows/setup/action.yaml b/.github/workflows/setup/action.yaml deleted file mode 100644 index f56e21271..000000000 --- a/.github/workflows/setup/action.yaml +++ /dev/null @@ -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 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index dfb28c9d7..f1f2a3fd3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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 diff --git a/SConstruct b/SConstruct index 8f6fe515d..dcee0d48e 100644 --- a/SConstruct +++ b/SConstruct @@ -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 diff --git a/cereal/log.capnp b/cereal/log.capnp index 6f8487c92..f1911320f 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -592,6 +592,7 @@ struct PandaState @0xa7649e2575e4591e { harnessStatus @21 :HarnessStatus; sbu1Voltage @35 :Float32; sbu2Voltage @36 :Float32; + soundOutputLevel @37 :UInt16; # can health canState0 @29 :PandaCanState; diff --git a/docs/README.md b/docs/README.md index 08dd4fa8b..12d0b6f5d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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** diff --git a/docs/car-porting/what-is-a-car-port.md b/docs/car-porting/what-is-a-car-port.md index 55cce94da..3480e4e5d 100644 --- a/docs/car-porting/what-is-a-car-port.md +++ b/docs/car-porting/what-is-a-car-port.md @@ -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 diff --git a/opendbc_repo b/opendbc_repo index c5ad50633..8b160905e 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit c5ad5063304987896fa0502553cb79f95acb1149 +Subproject commit 8b160905e046ccfc46b1817238e077cf8e55b6d7 diff --git a/panda b/panda index a0d3a4abe..a4e30942f 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit a0d3a4abe132358a2889929469d794182a582fb1 +Subproject commit a4e30942fae1d00d0e1dafb48e988413e93656d3 diff --git a/pyproject.toml b/pyproject.toml index 92cce5afa..012e19315 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/selfdrive/locationd/lagd.py b/selfdrive/locationd/lagd.py index 7f1d5fd03..f432eb88b 100755 --- a/selfdrive/locationd/lagd.py +++ b/selfdrive/locationd/lagd.py @@ -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 diff --git a/selfdrive/locationd/test/test_lagd.py b/selfdrive/locationd/test/test_lagd.py index e9b5aff6d..4728413d9 100644 --- a/selfdrive/locationd/test/test_lagd.py +++ b/selfdrive/locationd/test/test_lagd.py @@ -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) diff --git a/selfdrive/modeld/models/dmonitoring_model.onnx b/selfdrive/modeld/models/dmonitoring_model.onnx index 24234d4c5..dc621bed0 100644 --- a/selfdrive/modeld/models/dmonitoring_model.onnx +++ b/selfdrive/modeld/models/dmonitoring_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e8de9dc7df306700cce9c22b992e25b95a38f894c47adaea742a9cf8ba78e1a +oid sha256:7aff7ff1dc08bbaf562a8f77380ab5e5914f8557dba2f760d87e4d953f5604b0 size 7307246 diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 5e72513e9..d6d456688 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -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 diff --git a/selfdrive/pandad/pandad.cc b/selfdrive/pandad/pandad.cc index c6aa0135e..3d0a551d8 100644 --- a/selfdrive/pandad/pandad.cc +++ b/selfdrive/pandad/pandad.cc @@ -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) { diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 612fae4a7..7636e4674 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -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() diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py index a78c3341c..3aec5bbfe 100644 --- a/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -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 diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py index b04d69682..4d6bdfc3b 100644 --- a/selfdrive/ui/mici/layouts/settings/developer.py +++ b/selfdrive/ui/mici/layouts/settings/developer.py @@ -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() diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index fae9de657..908ca10b0 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -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) diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py index 5b416a813..eab9802e2 100644 --- a/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/selfdrive/ui/mici/layouts/settings/firehose.py @@ -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) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index bdae92456..ce3f1a817 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -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) diff --git a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index c69dc8b22..2fbe23c19 100644 --- a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -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 diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index 15fd68199..d996e01fe 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -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) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index 2e8129aae..a8cc6a876 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -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) diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py index 3ea650ece..231dafa8e 100644 --- a/selfdrive/ui/mici/widgets/button.py +++ b/selfdrive/ui/mici/widgets/button.py @@ -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) diff --git a/selfdrive/ui/sunnypilot/layouts/onboarding.py b/selfdrive/ui/sunnypilot/layouts/onboarding.py index 9ba9f0749..eed3cfd6b 100644 --- a/selfdrive/ui/sunnypilot/layouts/onboarding.py +++ b/selfdrive/ui/sunnypilot/layouts/onboarding.py @@ -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): diff --git a/selfdrive/ui/sunnypilot/layouts/settings/trips.py b/selfdrive/ui/sunnypilot/layouts/settings/trips.py index b389892d5..da9eb42d2 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/trips.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/trips.py @@ -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 diff --git a/selfdrive/ui/sunnypilot/mici/layouts/models.py b/selfdrive/ui/sunnypilot/mici/layouts/models.py index 49a0f49ff..5d8de4381 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -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() diff --git a/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py index 930853e55..03729f0f2 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py @@ -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() diff --git a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py index b4b4f21e2..c892db996 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -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 diff --git a/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py b/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py index de52bb462..c079c5a0e 100644 --- a/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py +++ b/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py @@ -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: diff --git a/system/hardware/tici/hardware.py b/system/hardware/tici/hardware.py index 2295ca3cb..8219f0a58 100644 --- a/system/hardware/tici/hardware.py +++ b/system/hardware/tici/hardware.py @@ -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: diff --git a/system/ui/lib/networkmanager.py b/system/ui/lib/networkmanager.py index c47928d8b..d2d6b30b1 100644 --- a/system/ui/lib/networkmanager.py +++ b/system/ui/lib/networkmanager.py @@ -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 diff --git a/system/ui/lib/tests/test_handle_state_change.py b/system/ui/lib/tests/test_handle_state_change.py new file mode 100644 index 000000000..69aae6fdf --- /dev/null +++ b/system/ui/lib/tests/test_handle_state_change.py @@ -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 diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index e2a90e433..1084e9e53 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -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,50 +386,72 @@ 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 - self._set_connecting(None) + 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: - self._set_connecting(None) + # 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, NMDeviceState.SECONDARIES, NMDeviceState.FAILED): @@ -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) - self._wifi_state = wifi_state - self._enqueue_callbacks(self._activated) - self._update_networks() + wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None)) - # Persist volatile connections (created by AddAndActivateConnection2) to disk + self._wifi_state = wifi_state + self._enqueue_callbacks(self._activated) + 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 diff --git a/system/ui/mici_reset.py b/system/ui/mici_reset.py index 357e67293..a459927ee 100755 --- a/system/ui/mici_reset.py +++ b/system/ui/mici_reset.py @@ -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() diff --git a/system/ui/widgets/nav_widget.py b/system/ui/widgets/nav_widget.py index 2944f47a7..02afc911b 100644 --- a/system/ui/widgets/nav_widget.py +++ b/system/ui/widgets/nav_widget.py @@ -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 diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index b2aeb5574..9d9f5663b 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -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) diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript index 137e77d85..e172278d9 100644 --- a/tools/cabana/SConscript +++ b/tools/cabana/SConscript @@ -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] diff --git a/tools/cabana/tests/test_cabana.cc b/tools/cabana/tests/test_cabana.cc index 4c11bfc8b..d9fcae6f2 100644 --- a/tools/cabana/tests/test_cabana.cc +++ b/tools/cabana/tests/test_cabana.cc @@ -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"); diff --git a/tools/op.sh b/tools/op.sh index f5c5b6082..7c20403a2 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -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 } diff --git a/tools/replay/SConscript b/tools/replay/SConscript index 47b25df16..b39cf6dab 100644 --- a/tools/replay/SConscript +++ b/tools/replay/SConscript @@ -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'): diff --git a/tools/replay/logreader.cc b/tools/replay/logreader.cc index 0d9e053ab..75abb8417 100644 --- a/tools/replay/logreader.cc +++ b/tools/replay/logreader.cc @@ -9,7 +9,9 @@ bool LogReader::load(const std::string &url, std::atomic *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); } } diff --git a/tools/replay/route.cc b/tools/replay/route.cc index 663c4b43c..ba0082826 100644 --- a/tools/replay/route.cc +++ b/tools/replay/route.cc @@ -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; diff --git a/tools/replay/tests/test_replay.cc b/tools/replay/tests/test_replay.cc index f4afc2996..aed3de59a 100644 --- a/tools/replay/tests/test_replay.cc +++ b/tools/replay/tests/test_replay.cc @@ -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); diff --git a/tools/replay/util.cc b/tools/replay/util.cc index cc37c19ec..481564322 100644 --- a/tools/replay/util.cc +++ b/tools/replay/util.cc @@ -1,5 +1,6 @@ #include "tools/replay/util.h" +#include #include #include @@ -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 *abort) { + return decompressBZ2((std::byte *)in.data(), in.size(), abort); +} + +std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic *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 *abort) { return decompressZST((std::byte *)in.data(), in.size(), abort); } diff --git a/tools/replay/util.h b/tools/replay/util.h index fc4d2d54f..1f61951d2 100644 --- a/tools/replay/util.h +++ b/tools/replay/util.h @@ -48,6 +48,8 @@ private: std::string sha256(const std::string &str); void precise_nano_sleep(int64_t nanoseconds, std::atomic &interrupt_requested); +std::string decompressBZ2(const std::string &in, std::atomic *abort = nullptr); +std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic *abort = nullptr); std::string decompressZST(const std::string &in, std::atomic *abort = nullptr); std::string decompressZST(const std::byte *in, size_t in_size, std::atomic *abort = nullptr); std::string getUrlWithoutQuery(const std::string &url); diff --git a/uv.lock b/uv.lock index b6dc99e79..63c8c4cff 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },