mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-04-06 16:53:58 +08:00
Sync: commaai/openpilot:master → sunnypilot/sunnypilot:master (#1727)
This commit is contained in:
55
.github/workflows/auto-cache/action.yaml
vendored
55
.github/workflows/auto-cache/action.yaml
vendored
@@ -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 }}
|
||||
2
.github/workflows/badges.yaml
vendored
2
.github/workflows/badges.yaml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/cereal_validation.yaml
vendored
4
.github/workflows/cereal_validation.yaml
vendored
@@ -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
|
||||
|
||||
21
.github/workflows/compile-openpilot/action.yaml
vendored
21
.github/workflows/compile-openpilot/action.yaml
vendored
@@ -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 }}
|
||||
6
.github/workflows/model_review.yaml
vendored
6
.github/workflows/model_review.yaml
vendored
@@ -17,6 +17,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -25,14 +27,12 @@ jobs:
|
||||
- run: git lfs pull
|
||||
- run: cd base && git lfs pull
|
||||
|
||||
- run: pip install onnx
|
||||
|
||||
- name: scripts/reporter.py
|
||||
id: report
|
||||
run: |
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "## Model Review" >> $GITHUB_OUTPUT
|
||||
MASTER_PATH=${{ github.workspace }}/base python scripts/reporter.py >> $GITHUB_OUTPUT
|
||||
PYTHONPATH=${{ github.workspace }} MASTER_PATH=${{ github.workspace }}/base python scripts/reporter.py >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post model report comment
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/repo-maintenance.yaml
vendored
4
.github/workflows/repo-maintenance.yaml
vendored
@@ -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
|
||||
|
||||
48
.github/workflows/setup-with-retry/action.yaml
vendored
48
.github/workflows/setup-with-retry/action.yaml
vendored
@@ -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
|
||||
56
.github/workflows/setup/action.yaml
vendored
56
.github/workflows/setup/action.yaml
vendored
@@ -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
|
||||
49
.github/workflows/tests.yaml
vendored
49
.github/workflows/tests.yaml
vendored
@@ -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
|
||||
|
||||
20
SConstruct
20
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
|
||||
|
||||
@@ -592,6 +592,7 @@ struct PandaState @0xa7649e2575e4591e {
|
||||
harnessStatus @21 :HarnessStatus;
|
||||
sbu1Voltage @35 :Float32;
|
||||
sbu2Voltage @36 :Float32;
|
||||
soundOutputLevel @37 :UInt16;
|
||||
|
||||
# can health
|
||||
canState0 @29 :PandaCanState;
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Submodule opendbc_repo updated: c5ad506330...8b160905e0
2
panda
2
panda
Submodule panda updated: a0d3a4abe1...a4e30942fa
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
906
system/ui/lib/tests/test_handle_state_change.py
Normal file
906
system/ui/lib/tests/test_handle_state_change.py
Normal file
@@ -0,0 +1,906 @@
|
||||
"""Tests for WifiManager._handle_state_change.
|
||||
|
||||
Tests the state machine in isolation by constructing a WifiManager with mocked
|
||||
DBus, then calling _handle_state_change directly with NM state transitions.
|
||||
"""
|
||||
import pytest
|
||||
from jeepney.low_level import MessageType
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus
|
||||
|
||||
|
||||
def _make_wm(mocker: MockerFixture, connections=None):
|
||||
"""Create a WifiManager with only the fields _handle_state_change touches."""
|
||||
mocker.patch.object(WifiManager, '_initialize')
|
||||
wm = WifiManager.__new__(WifiManager)
|
||||
wm._exit = True # prevent stop() from doing anything in __del__
|
||||
wm._conn_monitor = mocker.MagicMock()
|
||||
wm._connections = dict(connections or {})
|
||||
wm._wifi_state = WifiState()
|
||||
wm._user_epoch = 0
|
||||
wm._callback_queue = []
|
||||
wm._need_auth = []
|
||||
wm._activated = []
|
||||
wm._update_networks = mocker.MagicMock()
|
||||
wm._update_active_connection_info = mocker.MagicMock()
|
||||
wm._get_active_wifi_connection = mocker.MagicMock(return_value=(None, None))
|
||||
return wm
|
||||
|
||||
|
||||
def fire(wm: WifiManager, new_state: int, prev_state: int = NMDeviceState.UNKNOWN,
|
||||
reason: int = NMDeviceStateReason.NONE) -> None:
|
||||
"""Feed a state change into the handler."""
|
||||
wm._handle_state_change(new_state, prev_state, reason)
|
||||
|
||||
|
||||
def fire_wpa_connect(wm: WifiManager) -> None:
|
||||
"""WPA handshake then IP negotiation through ACTIVATED, as seen on device."""
|
||||
fire(wm, NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.IP_CONFIG)
|
||||
fire(wm, NMDeviceState.IP_CHECK)
|
||||
fire(wm, NMDeviceState.SECONDARIES)
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDisconnected:
|
||||
def test_generic_disconnect_clears_state(self, mocker):
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.UNKNOWN)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
wm._update_networks.assert_not_called()
|
||||
|
||||
def test_new_activation_is_noop(self, mocker):
|
||||
"""NEW_ACTIVATION means NM is about to connect to another network — don't clear."""
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="OldNet", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
|
||||
assert wm._wifi_state.ssid == "OldNet"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_connection_removed_keeps_other_connecting(self, mocker):
|
||||
"""Forget A while connecting to B: CONNECTION_REMOVED for A must not clear B."""
|
||||
wm = _make_wm(mocker, connections={"B": "/path/B"})
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_connection_removed_clears_when_forgotten(self, mocker):
|
||||
"""Forget A: A is no longer in _connections, so state should clear."""
|
||||
wm = _make_wm(mocker, connections={})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
|
||||
class TestDeactivating:
|
||||
def test_deactivating_noop_for_non_connection_removed(self, mocker):
|
||||
"""DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op."""
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
|
||||
|
||||
assert wm._wifi_state.ssid == "Net"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
@pytest.mark.parametrize("status, expected_clears", [
|
||||
(ConnectStatus.CONNECTED, True),
|
||||
(ConnectStatus.CONNECTING, False),
|
||||
])
|
||||
def test_deactivating_connection_removed(self, mocker, status, expected_clears):
|
||||
"""DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING.
|
||||
|
||||
CONNECTED: forgetting the current network. The forgotten callback fires between
|
||||
DEACTIVATING and DISCONNECTED — must clear here so the UI doesn't flash "connected"
|
||||
after the eager _network_forgetting flag resets.
|
||||
|
||||
CONNECTING: forget A while connecting to B. DEACTIVATING fires for A's removal,
|
||||
but B's CONNECTING state must be preserved.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"B": "/path/B"})
|
||||
wm._wifi_state = WifiState(ssid="B" if status == ConnectStatus.CONNECTING else "A", status=status)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
|
||||
if expected_clears:
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
else:
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
|
||||
class TestPrepareConfig:
|
||||
def test_user_initiated_skips_dbus_lookup(self, mocker):
|
||||
"""User called _set_connecting('B') — PREPARE must not overwrite via DBus.
|
||||
|
||||
Reproduced on device: rapidly tap A then B. PREPARE's DBus lookup returns A's
|
||||
stale conn_path, overwriting ssid to A for 1-2 frames. UI shows the "connecting"
|
||||
indicator briefly jump to the wrong network row then back.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._set_connecting("B")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/A", {})
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
wm._get_active_wifi_connection.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("state", [NMDeviceState.PREPARE, NMDeviceState.CONFIG])
|
||||
def test_auto_connect_looks_up_ssid(self, mocker, state):
|
||||
"""Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM."""
|
||||
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
|
||||
|
||||
fire(wm, state)
|
||||
|
||||
assert wm._wifi_state.ssid == "AutoNet"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_auto_connect_dbus_fails(self, mocker):
|
||||
"""Auto-connection but DBus returns None: ssid stays None, status CONNECTING."""
|
||||
wm = _make_wm(mocker)
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_auto_connect_conn_path_not_in_connections(self, mocker):
|
||||
"""DBus returns a conn_path that doesn't match any known connection."""
|
||||
wm = _make_wm(mocker, connections={"Other": "/path/other"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/unknown", {})
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
|
||||
class TestNeedAuth:
|
||||
def test_wrong_password_fires_callback(self, mocker):
|
||||
"""NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password."""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("SecNet")
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("SecNet")
|
||||
|
||||
def test_failed_no_secrets_fires_callback(self, mocker):
|
||||
"""FAILED+NO_SECRETS = wrong password (weak/gone network).
|
||||
|
||||
Confirmed on device: also fires when a hotspot turns off during connection.
|
||||
NM can't complete the WPA handshake (AP vanished) and reports NO_SECRETS
|
||||
rather than SSID_NOT_FOUND. The need_auth callback fires, so the UI shows
|
||||
"wrong password" — a false positive, but same signal path.
|
||||
|
||||
Real device sequence (new connection, hotspot turned off immediately):
|
||||
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("WeakNet")
|
||||
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("WeakNet")
|
||||
|
||||
def test_need_auth_then_failed_no_double_fire(self, mocker):
|
||||
"""Real device sends NEED_AUTH(SUPPLICANT_DISCONNECT) then FAILED(NO_SECRETS) back-to-back.
|
||||
|
||||
The first clears ssid, so the second must not fire a duplicate callback.
|
||||
Real device sequence: NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) → FAILED(NEED_AUTH, NO_SECRETS)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("BadPass")
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
|
||||
reason=NMDeviceStateReason.NO_SECRETS)
|
||||
assert len(wm._callback_queue) == 1 # no duplicate
|
||||
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("BadPass")
|
||||
|
||||
def test_no_ssid_no_callback(self, mocker):
|
||||
"""If ssid is None when NEED_AUTH fires, no callback enqueued."""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
def test_interrupted_auth_ignored(self, mocker):
|
||||
"""Switching A->B: NEED_AUTH from A (prev=DISCONNECTED) must not fire callback.
|
||||
|
||||
Reproduced on device: rapidly switching between two saved networks can trigger a
|
||||
rare false "wrong password" dialog for the previous network, even though both have
|
||||
correct passwords. The stale NEED_AUTH has prev_state=DISCONNECTED (not CONFIG).
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("A")
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
|
||||
class TestPassthroughStates:
|
||||
"""NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops."""
|
||||
|
||||
@pytest.mark.parametrize("state", [
|
||||
NMDeviceState.NEED_AUTH,
|
||||
NMDeviceState.IP_CONFIG,
|
||||
NMDeviceState.IP_CHECK,
|
||||
NMDeviceState.SECONDARIES,
|
||||
NMDeviceState.FAILED,
|
||||
])
|
||||
def test_passthrough_is_noop(self, mocker, state):
|
||||
wm = _make_wm(mocker)
|
||||
wm._set_connecting("Net")
|
||||
|
||||
fire(wm, state, reason=NMDeviceStateReason.NONE)
|
||||
|
||||
assert wm._wifi_state.ssid == "Net"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
|
||||
class TestActivated:
|
||||
def test_sets_connected(self, mocker):
|
||||
"""ACTIVATED sets status to CONNECTED and fires callback."""
|
||||
wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(activated=cb)
|
||||
wm._set_connecting("MyNet")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/mynet", {})
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "MyNet"
|
||||
assert len(wm._callback_queue) == 1
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once()
|
||||
|
||||
def test_conn_path_none_still_connected(self, mocker):
|
||||
"""ACTIVATED but DBus returns None: status CONNECTED, ssid unchanged."""
|
||||
wm = _make_wm(mocker)
|
||||
wm._set_connecting("MyNet")
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "MyNet"
|
||||
|
||||
def test_activated_side_effects(self, mocker):
|
||||
"""ACTIVATED persists the volatile connection to disk and updates active connection info."""
|
||||
wm = _make_wm(mocker, connections={"Net": "/path/net"})
|
||||
wm._set_connecting("Net")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/net", {})
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
wm._conn_monitor.send_and_get_reply.assert_called_once()
|
||||
wm._update_active_connection_info.assert_called_once()
|
||||
wm._update_networks.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread races: _set_connecting on main thread vs _handle_state_change on monitor thread.
|
||||
# Uses side_effect on the DBus mock to simulate _set_connecting running mid-handler.
|
||||
# The epoch counter detects that a user action occurred during the slow DBus call
|
||||
# and discards the stale update.
|
||||
# ---------------------------------------------------------------------------
|
||||
# The deterministic fixes (skip DBus lookup when ssid already set, prev_state guard
|
||||
# on NEED_AUTH, DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED
|
||||
# guard) shrink these race windows significantly. The epoch counter closes the
|
||||
# remaining gaps.
|
||||
|
||||
class TestThreadRaces:
|
||||
def test_prepare_race_user_tap_during_dbus(self, mocker):
|
||||
"""User taps B while PREPARE's DBus call is in flight for auto-connect.
|
||||
|
||||
Monitor thread reads wifi_state (ssid=None), starts DBus call.
|
||||
Main thread: _set_connecting("B"). Monitor thread writes back stale ssid from DBus.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
|
||||
def user_taps_b_during_dbus(*args, **kwargs):
|
||||
wm._set_connecting("B")
|
||||
return ("/path/A", {})
|
||||
|
||||
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_activated_race_user_tap_during_dbus(self, mocker):
|
||||
"""User taps B right as A finishes connecting (ACTIVATED handler running).
|
||||
|
||||
Monitor thread reads wifi_state (A, CONNECTING), starts DBus call.
|
||||
Main thread: _set_connecting("B"). Monitor thread writes (A, CONNECTED), losing B.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._set_connecting("A")
|
||||
|
||||
def user_taps_b_during_dbus(*args, **kwargs):
|
||||
wm._set_connecting("B")
|
||||
return ("/path/A", {})
|
||||
|
||||
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_init_wifi_state_race_user_tap_during_dbus(self, mocker):
|
||||
"""User taps B while _init_wifi_state's DBus calls are in flight.
|
||||
|
||||
_init_wifi_state runs from set_active(True) or worker error paths. It does
|
||||
2 DBus calls (device State property + _get_active_wifi_connection) then
|
||||
unconditionally writes _wifi_state. If the user taps a network during those
|
||||
calls, _set_connecting("B") is overwritten with stale NM ground truth.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._wifi_device = "/dev/wifi0"
|
||||
wm._router_main = mocker.MagicMock()
|
||||
|
||||
state_reply = mocker.MagicMock()
|
||||
state_reply.body = [('u', NMDeviceState.ACTIVATED)]
|
||||
wm._router_main.send_and_get_reply.return_value = state_reply
|
||||
|
||||
def user_taps_b_during_dbus(*args, **kwargs):
|
||||
wm._set_connecting("B")
|
||||
return ("/path/A", {})
|
||||
|
||||
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
|
||||
|
||||
wm._init_wifi_state()
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full sequences (NM signal order from real devices)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFullSequences:
|
||||
def test_normal_connect(self, mocker):
|
||||
"""User connects to saved network: full happy path.
|
||||
|
||||
Real device sequence (switching from another connected network):
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"Home": "/path/home"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/home", {})
|
||||
|
||||
wm._set_connecting("Home")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.IP_CONFIG)
|
||||
fire(wm, NMDeviceState.IP_CHECK)
|
||||
fire(wm, NMDeviceState.SECONDARIES)
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "Home"
|
||||
|
||||
def test_wrong_password_then_retry(self, mocker):
|
||||
"""Wrong password → NEED_AUTH → FAILED → NM auto-reconnects to saved network.
|
||||
|
||||
Confirmed on device: wrong password for Shane's iPhone, NM auto-connected to unifi.
|
||||
|
||||
Real device sequence (switching from a connected network):
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) ← WPA handshake
|
||||
→ PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) ← wrong password
|
||||
→ FAILED(NEED_AUTH, NO_SECRETS) ← NM gives up
|
||||
→ DISCONNECTED(FAILED, NONE)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED ← auto-reconnect to other saved network
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"Sec": "/path/sec"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
wm._set_connecting("Sec")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
# FAILED(NO_SECRETS) follows but ssid is already cleared — no double-fire
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
|
||||
|
||||
# Retry
|
||||
wm._callback_queue.clear()
|
||||
wm._set_connecting("Sec")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/sec", {})
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_switch_saved_networks(self, mocker):
|
||||
"""Switch from A to B (both saved): NM signal sequence from real device.
|
||||
|
||||
Real device sequence:
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
|
||||
def test_rapid_switch_no_false_wrong_password(self, mocker):
|
||||
"""Switch A→B quickly: A's interrupted NEED_AUTH must NOT show wrong password.
|
||||
|
||||
NOTE: The late NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) is common when rapidly
|
||||
switching between networks with wrong/new passwords. Less common when switching between
|
||||
saved networks with correct passwords. Not guaranteed — some switches skip it and go
|
||||
straight from DISCONNECTED to PREPARE. The prev_state is consistently DISCONNECTED
|
||||
for stale signals, so the prev_state guard reliably distinguishes them.
|
||||
|
||||
Worst-case signal sequence this protects against:
|
||||
DEACTIVATING(NEW_ACTIVATION) → DISCONNECTED(NEW_ACTIVATION)
|
||||
→ NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) ← A's stale auth failure
|
||||
→ PREPARE → CONFIG → ... → ACTIVATED ← B connects
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_forget_while_connecting(self, mocker):
|
||||
"""Forget the network we're currently connecting to (not yet ACTIVATED).
|
||||
|
||||
Confirmed on device: connected to unifi, tapped Shane's iPhone, then forgot
|
||||
Shane's iPhone while at CONFIG. NM auto-connected to unifi afterward.
|
||||
|
||||
Real device sequence (switching then forgetting mid-connection):
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ DEACTIVATING(CONFIG, CONNECTION_REMOVED) ← forget at CONFIG
|
||||
→ DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
|
||||
→ PREPARE → CONFIG → ... → ACTIVATED ← NM auto-connects to other saved network
|
||||
|
||||
Note: DEACTIVATING fires from CONFIG (not ACTIVATED). wifi_state.status is
|
||||
CONNECTING, so the DEACTIVATING handler is a no-op. DISCONNECTED clears state
|
||||
(ssid removed from _connections by ConnectionRemoved), then PREPARE recovers
|
||||
via DBus lookup for the auto-connect.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "Other": "/path/other"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/other", {})
|
||||
|
||||
wm._set_connecting("A")
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
# User forgets A: ConnectionRemoved processed first, then state changes
|
||||
del wm._connections["A"]
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING # DEACTIVATING preserves CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
# NM auto-connects to another saved network
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
assert wm._wifi_state.ssid == "Other"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "Other"
|
||||
|
||||
def test_forget_connected_network(self, mocker):
|
||||
"""Forget the currently connected network (not switching to another).
|
||||
|
||||
Real device sequence:
|
||||
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
|
||||
|
||||
ConnectionRemoved signal may or may not have been processed before state changes.
|
||||
Either way, state must clear — we're forgetting what we're connected to, not switching.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
# DISCONNECTED follows — harmless since state is already cleared
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
def test_forget_A_connect_B(self, mocker):
|
||||
"""Forget A while connecting to B: full signal sequence.
|
||||
|
||||
Real device sequence:
|
||||
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
|
||||
|
||||
Signal order:
|
||||
1. User: _set_connecting("B"), forget("A") removes A from _connections
|
||||
2. NewConnection for B arrives → _connections["B"] = ...
|
||||
3. DEACTIVATING(CONNECTION_REMOVED) — no-op
|
||||
4. DISCONNECTED(CONNECTION_REMOVED) — B is in _connections, must not clear
|
||||
5. PREPARE → CONFIG → NEED_AUTH → PREPARE → CONFIG → ... → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
wm._set_connecting("B")
|
||||
del wm._connections["A"]
|
||||
wm._connections["B"] = "/path/B"
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
|
||||
def test_forget_A_connect_B_late_new_connection(self, mocker):
|
||||
"""Forget A, connect B: NewConnection for B arrives AFTER DISCONNECTED.
|
||||
|
||||
This is the worst-case race: B isn't in _connections when DISCONNECTED fires,
|
||||
so the guard can't protect it and state clears. PREPARE must recover by doing
|
||||
the DBus lookup (ssid is None at that point).
|
||||
|
||||
Signal order:
|
||||
1. User: _set_connecting("B"), forget("A") removes A from _connections
|
||||
2. DEACTIVATING(CONNECTION_REMOVED) — B NOT in _connections, should be no-op
|
||||
3. DISCONNECTED(CONNECTION_REMOVED) — B STILL NOT in _connections, clears state
|
||||
4. NewConnection for B arrives late → _connections["B"] = ...
|
||||
5. PREPARE (ssid=None, so DBus lookup recovers) → CONFIG → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
wm._set_connecting("B")
|
||||
del wm._connections["A"]
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
# B not in _connections yet, so state clears — this is the known edge case
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
# NewConnection arrives late
|
||||
wm._connections["B"] = "/path/B"
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
|
||||
# PREPARE recovers: ssid is None so it looks up from DBus
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
|
||||
def test_auto_connect(self, mocker):
|
||||
"""NM auto-connects (no user action, ssid starts None)."""
|
||||
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
assert wm._wifi_state.ssid == "AutoNet"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "AutoNet"
|
||||
|
||||
def test_network_lost_during_connection(self, mocker):
|
||||
"""Hotspot turned off while connecting (before ACTIVATED).
|
||||
|
||||
Confirmed on device: started new connection to Shane's iPhone, immediately
|
||||
turned off the hotspot. NM can't complete WPA handshake and reports
|
||||
FAILED(NO_SECRETS) — same signal as wrong password (false positive).
|
||||
|
||||
Real device sequence:
|
||||
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
|
||||
|
||||
Note: no DEACTIVATING, no SUPPLICANT_DISCONNECT. The NEED_AUTH(CONFIG, NONE) is the
|
||||
normal WPA handshake (not an error). NM gives up with NO_SECRETS because the AP
|
||||
vanished mid-handshake.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"Hotspot": "/path/hs"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
wm._set_connecting("Hotspot")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
# Second NEED_AUTH(CONFIG, NONE) — NM retries handshake, AP vanishing
|
||||
fire(wm, NMDeviceState.NEED_AUTH)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
# NM gives up — reports NO_SECRETS (same as wrong password)
|
||||
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
|
||||
reason=NMDeviceStateReason.NO_SECRETS)
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("Hotspot")
|
||||
|
||||
@pytest.mark.xfail(reason="TODO: FAILED(SSID_NOT_FOUND) should emit error for UI")
|
||||
def test_ssid_not_found(self, mocker):
|
||||
"""Network drops off while connected — hotspot turned off.
|
||||
|
||||
NM docs: SSID_NOT_FOUND (53) = "The WiFi network could not be found"
|
||||
|
||||
Confirmed on device: connected to Shane's iPhone, then turned off the hotspot.
|
||||
No DEACTIVATING fires — NM goes straight from ACTIVATED to FAILED(SSID_NOT_FOUND).
|
||||
NM retries connecting (PREPARE → CONFIG → ... → FAILED(CONFIG, SSID_NOT_FOUND))
|
||||
before finally giving up with DISCONNECTED.
|
||||
|
||||
NOTE: turning off a hotspot during initial connection (before ACTIVATED) typically
|
||||
produces FAILED(NO_SECRETS) instead of SSID_NOT_FOUND (see test_failed_no_secrets).
|
||||
|
||||
Real device sequence (hotspot turned off while connected):
|
||||
FAILED(ACTIVATED, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ FAILED(CONFIG, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
|
||||
|
||||
The UI error callback mechanism is intentionally deferred — for now just clear state.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"GoneNet": "/path/gone"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
wm._set_connecting("GoneNet")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.SSID_NOT_FOUND)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert wm._wifi_state.ssid is None
|
||||
|
||||
def test_failed_then_disconnected_clears_state(self, mocker):
|
||||
"""After FAILED, NM always transitions to DISCONNECTED to clean up.
|
||||
|
||||
NM docs: FAILED (120) = "failed to connect, cleaning up the connection request"
|
||||
Full sequence: ... → FAILED(reason) → DISCONNECTED(NONE)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
wm._set_connecting("Net")
|
||||
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NONE)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING # FAILED(NONE) is a no-op
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NONE)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
def test_user_requested_disconnect(self, mocker):
|
||||
"""User explicitly disconnects from the network.
|
||||
|
||||
NM docs: USER_REQUESTED (39) = "Device disconnected by user or client"
|
||||
Expected sequence: DEACTIVATING(USER_REQUESTED) → DISCONNECTED(USER_REQUESTED)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="MyNet", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.USER_REQUESTED)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker error recovery: DBus errors in activate/connect re-sync with NM
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verified on device: when ActivateConnection returns UnknownConnection error,
|
||||
# NM emits no state signals. The worker error path is the only recovery point.
|
||||
|
||||
class TestWorkerErrorRecovery:
|
||||
"""Worker threads re-sync with NM via _init_wifi_state on DBus errors,
|
||||
preserving actual NM state instead of blindly clearing to DISCONNECTED."""
|
||||
|
||||
def _mock_init_restores(self, wm, mocker, ssid, status):
|
||||
"""Replace _init_wifi_state with a mock that simulates NM reporting the given state."""
|
||||
mock = mocker.MagicMock(
|
||||
side_effect=lambda: setattr(wm, '_wifi_state', WifiState(ssid=ssid, status=status))
|
||||
)
|
||||
wm._init_wifi_state = mock
|
||||
return mock
|
||||
|
||||
def test_activate_dbus_error_resyncs(self, mocker):
|
||||
"""ActivateConnection returns DBus error while A is connected.
|
||||
NM rejects the request — no state signals emitted. Worker must re-read NM
|
||||
state to discover A is still connected, not clear to DISCONNECTED.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._wifi_device = "/dev/wifi0"
|
||||
wm._nm = mocker.MagicMock()
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._router_main = mocker.MagicMock()
|
||||
|
||||
error_reply = mocker.MagicMock()
|
||||
error_reply.header.message_type = MessageType.error
|
||||
wm._router_main.send_and_get_reply.return_value = error_reply
|
||||
|
||||
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
|
||||
|
||||
wm.activate_connection("B", block=True)
|
||||
|
||||
mock_init.assert_called_once()
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_connect_to_network_dbus_error_resyncs(self, mocker):
|
||||
"""AddAndActivateConnection2 returns DBus error while A is connected."""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_device = "/dev/wifi0"
|
||||
wm._nm = mocker.MagicMock()
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._router_main = mocker.MagicMock()
|
||||
wm._forgotten = []
|
||||
|
||||
error_reply = mocker.MagicMock()
|
||||
error_reply.header.message_type = MessageType.error
|
||||
wm._router_main.send_and_get_reply.return_value = error_reply
|
||||
|
||||
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
|
||||
|
||||
# Run worker thread synchronously
|
||||
workers = []
|
||||
mocker.patch('openpilot.system.ui.lib.wifi_manager.threading.Thread',
|
||||
side_effect=lambda target, **kw: type('T', (), {'start': lambda self: workers.append(target)})())
|
||||
|
||||
wm.connect_to_network("B", "password123")
|
||||
workers[-1]()
|
||||
|
||||
mock_init.assert_called_once()
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
44
tools/op.sh
44
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
|
||||
}
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
bool LogReader::load(const std::string &url, std::atomic<bool> *abort, bool local_cache, int chunk_size, int retries) {
|
||||
std::string data = FileReader(local_cache, chunk_size, retries).read(url, abort);
|
||||
if (!data.empty()) {
|
||||
if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) {
|
||||
if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) {
|
||||
data = decompressBZ2(data, abort);
|
||||
} else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) {
|
||||
data = decompressZST(data, abort);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "tools/replay/util.h"
|
||||
|
||||
#include <bzlib.h>
|
||||
#include <curl/curl.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
@@ -279,6 +280,47 @@ bool httpDownload(const std::string &url, const std::string &file, size_t chunk_
|
||||
return httpDownload(url, of, chunk_size, size, abort);
|
||||
}
|
||||
|
||||
std::string decompressBZ2(const std::string &in, std::atomic<bool> *abort) {
|
||||
return decompressBZ2((std::byte *)in.data(), in.size(), abort);
|
||||
}
|
||||
|
||||
std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic<bool> *abort) {
|
||||
if (in_size == 0) return {};
|
||||
|
||||
bz_stream strm = {};
|
||||
int bzerror = BZ2_bzDecompressInit(&strm, 0, 0);
|
||||
assert(bzerror == BZ_OK);
|
||||
|
||||
strm.next_in = (char *)in;
|
||||
strm.avail_in = in_size;
|
||||
std::string out(in_size * 5, '\0');
|
||||
do {
|
||||
strm.next_out = (char *)(&out[strm.total_out_lo32]);
|
||||
strm.avail_out = out.size() - strm.total_out_lo32;
|
||||
|
||||
const char *prev_write_pos = strm.next_out;
|
||||
bzerror = BZ2_bzDecompress(&strm);
|
||||
if (bzerror == BZ_OK && prev_write_pos == strm.next_out) {
|
||||
// content is corrupt
|
||||
bzerror = BZ_STREAM_END;
|
||||
rWarning("decompressBZ2 error: content is corrupt");
|
||||
break;
|
||||
}
|
||||
|
||||
if (bzerror == BZ_OK && strm.avail_in > 0 && strm.avail_out == 0) {
|
||||
out.resize(out.size() * 2);
|
||||
}
|
||||
} while (bzerror == BZ_OK && !(abort && *abort));
|
||||
|
||||
BZ2_bzDecompressEnd(&strm);
|
||||
if (bzerror == BZ_STREAM_END && !(abort && *abort)) {
|
||||
out.resize(strm.total_out_lo32);
|
||||
out.shrink_to_fit();
|
||||
return out;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string decompressZST(const std::string &in, std::atomic<bool> *abort) {
|
||||
return decompressZST((std::byte *)in.data(), in.size(), abort);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ private:
|
||||
|
||||
std::string sha256(const std::string &str);
|
||||
void precise_nano_sleep(int64_t nanoseconds, std::atomic<bool> &interrupt_requested);
|
||||
std::string decompressBZ2(const std::string &in, std::atomic<bool> *abort = nullptr);
|
||||
std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic<bool> *abort = nullptr);
|
||||
std::string decompressZST(const std::string &in, std::atomic<bool> *abort = nullptr);
|
||||
std::string decompressZST(const std::byte *in, size_t in_size, std::atomic<bool> *abort = nullptr);
|
||||
std::string getUrlWithoutQuery(const std::string &url);
|
||||
|
||||
7
uv.lock
generated
7
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user